feat:备份恢复功能

This commit is contained in:
waner
2026-04-29 16:45:52 +08:00
parent cd647e8b00
commit ae72769741
86 changed files with 2648 additions and 3250 deletions
@@ -1,5 +1,8 @@
package com.cisd.tms.modules.backup.config;
import com.cisd.tms.modules.backup.dto.support.BackupResourceKind;
import com.cisd.tms.modules.backup.dto.support.ConfiguredFileBackupResource;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "tms.backup")
@@ -9,18 +12,24 @@ public class ResourceBackupProperties {
private String precheckStoreDir = "/home/tms/tmp/resource-restore-precheck";
private String restoreTaskRootDir = "/home/tms/tmp/resource-restore";
private String tmsScriptPath = "/home/tms/scripts/tms.sh";
private String runnerLaunchScriptPath = "/home/tms/bin/resource-restore/start-runner.sh";
private String mysqldumpPath = "mysqldump";
private String mysqlPath = "mysql";
private String restoreDbScriptPath = "/home/tms/bin/resource-restore/restore-db.sh";
private String mqReplayScriptPath = "/home/tms/bin/resource-restore/replay-mq.sh";
private String tmsDatabaseName = "";
private String cmepDatabaseName = "CMEP";
private String healthCheckUrl = "http://127.0.0.1:8080/actuator/health";
private int healthCheckTimeoutSeconds = 5;
private boolean runnerAutoLaunchEnabled = false;
private int runnerHeartbeatTimeoutSeconds = 300;
private int storageEncKeyIndex = 1;
private int authenticitySignKeyIndex = 1;
private String restoreApplyScriptPath = "/home/tms/bin/resource-restore/apply-resource-backup.sh";
private List<String> allowedRestoreRoots = List.of(
"/home/tms/config",
"/home/cmep4i",
"/home/cmep4d",
"/home/tms/tmp/resource-restore"
);
private List<ConfiguredFileBackupResource> fileResources = List.of();
public String getOutputDir() {
return outputDir;
@@ -54,14 +63,6 @@ public class ResourceBackupProperties {
this.tmsScriptPath = tmsScriptPath;
}
public String getRunnerLaunchScriptPath() {
return runnerLaunchScriptPath;
}
public void setRunnerLaunchScriptPath(String runnerLaunchScriptPath) {
this.runnerLaunchScriptPath = runnerLaunchScriptPath;
}
public String getMysqldumpPath() {
return mysqldumpPath;
}
@@ -86,6 +87,14 @@ public class ResourceBackupProperties {
this.restoreDbScriptPath = restoreDbScriptPath;
}
public String getMqReplayScriptPath() {
return mqReplayScriptPath;
}
public void setMqReplayScriptPath(String mqReplayScriptPath) {
this.mqReplayScriptPath = mqReplayScriptPath;
}
public String getTmsDatabaseName() {
return tmsDatabaseName;
}
@@ -118,22 +127,6 @@ public class ResourceBackupProperties {
this.healthCheckTimeoutSeconds = healthCheckTimeoutSeconds;
}
public boolean getRunnerAutoLaunchEnabled() {
return runnerAutoLaunchEnabled;
}
public void setRunnerAutoLaunchEnabled(boolean runnerAutoLaunchEnabled) {
this.runnerAutoLaunchEnabled = runnerAutoLaunchEnabled;
}
public int getRunnerHeartbeatTimeoutSeconds() {
return runnerHeartbeatTimeoutSeconds;
}
public void setRunnerHeartbeatTimeoutSeconds(int runnerHeartbeatTimeoutSeconds) {
this.runnerHeartbeatTimeoutSeconds = runnerHeartbeatTimeoutSeconds;
}
public int getStorageEncKeyIndex() {
return storageEncKeyIndex;
}
@@ -149,4 +142,58 @@ public class ResourceBackupProperties {
public void setAuthenticitySignKeyIndex(int authenticitySignKeyIndex) {
this.authenticitySignKeyIndex = authenticitySignKeyIndex;
}
public String getRestoreApplyScriptPath() {
return restoreApplyScriptPath;
}
public void setRestoreApplyScriptPath(String restoreApplyScriptPath) {
this.restoreApplyScriptPath = restoreApplyScriptPath;
}
public List<String> getAllowedRestoreRoots() {
return allowedRestoreRoots;
}
public void setAllowedRestoreRoots(List<String> allowedRestoreRoots) {
this.allowedRestoreRoots = allowedRestoreRoots;
}
public List<ConfiguredFileBackupResource> getFileResources() {
return fileResources;
}
public void setFileResources(List<ConfiguredFileBackupResource> fileResources) {
this.fileResources = fileResources;
}
public List<ConfiguredFileBackupResource> getDefaultFileResources() {
ConfiguredFileBackupResource tmsConfig = fileResource(
"TMS_CONFIG",
"DIR",
"/home/tms/config",
"/home/tms/config",
true
);
ConfiguredFileBackupResource cpConfig = fileResource("CP_CONFIG", "FILE", "", "", true);
ConfiguredFileBackupResource receiverLicense = fileResource("RECEIVER_LICENSE", "FILE", "", "", true);
return List.of(tmsConfig, cpConfig, receiverLicense);
}
private static ConfiguredFileBackupResource fileResource(
String code,
String mode,
String path,
String restorePath,
boolean required
) {
ConfiguredFileBackupResource resource = new ConfiguredFileBackupResource();
resource.setCode(code);
resource.setKind(BackupResourceKind.FILE.name());
resource.setMode(mode);
resource.setPath(path);
resource.setRestorePath(restorePath);
resource.setRequired(required);
return resource;
}
}
@@ -4,7 +4,6 @@ import com.cisd.tms.common.api.ApiResponse;
import com.cisd.tms.modules.backup.dto.request.CreateResourceBackupRequest;
import com.cisd.tms.modules.backup.dto.response.CreateResourceBackupResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceBackupTaskDetailResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceKeysetStatusResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepItemResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepLogResponse;
import com.cisd.tms.modules.backup.service.ResourceBackupService;
@@ -39,12 +38,6 @@ public class ResourceBackupController {
private final ResourceBackupService resourceBackupService;
@GetMapping("/keyset/status")
@Operation(summary = "查询资源备份密钥状态", description = "返回资源备份恢复前置密钥状态和公钥指纹。")
public ApiResponse<ResourceKeysetStatusResponse> keysetStatus() {
return ApiResponse.success(resourceBackupService.getKeysetStatus());
}
@PostMapping
@ReplayProtected
@Operation(summary = "创建资源备份任务", description = "受理资源备份请求并返回备份任务号。")
@@ -7,8 +7,8 @@ import com.cisd.tms.integration.crypto.pcie.model.EccInternalSignRequest;
import com.cisd.tms.integration.crypto.pcie.model.EccInternalVerifyRequest;
import com.cisd.tms.integration.crypto.pcie.service.PcieCryptoService;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import java.nio.charset.StandardCharsets;
import java.security.Security;
import java.util.Arrays;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -75,6 +75,8 @@ public class ResourceEnvelopeCryptoServiceImpl implements ResourceEnvelopeCrypto
return sealedPayload;
} catch (Exception ex) {
throw new IllegalStateException("failed to seal resource payload", ex);
} finally {
wipe(sm4Key);
}
}
@@ -128,7 +130,7 @@ public class ResourceEnvelopeCryptoServiceImpl implements ResourceEnvelopeCrypto
decryptRequest.setKeyIndex(resourceBackupProperties.getStorageEncKeyIndex());
decryptRequest.setCipherBlob(wrappedDek);
decryptRequest.setOutBufferSize(dekLength);
byte[] sm4Key = normalize(pcieCryptoService.sdfeInternalDecryptEcc(decryptRequest));
byte[] sm4Key = normalizeAndWipe(pcieCryptoService.sdfeInternalDecryptEcc(decryptRequest));
byte[] iv = Base64.getDecoder().decode(String.valueOf(envelope.get("ivBase64")));
try {
Cipher cipher = Cipher.getInstance("SM4/CBC/PKCS7Padding", PROVIDER);
@@ -136,6 +138,8 @@ public class ResourceEnvelopeCryptoServiceImpl implements ResourceEnvelopeCrypto
return cipher.doFinal(cipherBytes);
} catch (Exception ex) {
throw new IllegalStateException("failed to unseal resource payload", ex);
} finally {
wipe(sm4Key);
}
}
@@ -165,6 +169,16 @@ public class ResourceEnvelopeCryptoServiceImpl implements ResourceEnvelopeCrypto
return normalized;
}
private static byte[] normalizeAndWipe(BackupDataResult result) {
try {
return normalize(result);
} finally {
if (result != null) {
wipe(result.getData());
}
}
}
private static int parseInt(Object value, int defaultValue) {
if (value == null) {
return defaultValue;
@@ -182,4 +196,10 @@ public class ResourceEnvelopeCryptoServiceImpl implements ResourceEnvelopeCrypto
private static String buildFingerprint(String prefix, int keyIndex) {
return prefix + ":" + keyIndex;
}
private static void wipe(byte[] bytes) {
if (bytes != null) {
Arrays.fill(bytes, (byte) 0);
}
}
}
@@ -28,24 +28,15 @@ public class ResourceDiscoveryServiceImpl implements ResourceDiscoveryService {
}
List<DiscoveredResourceItem> items = new ArrayList<>();
items.add(item("FILE", "TMS_CONFIG", initExecutorProperties.getTmsConfigDir(), true));
items.add(item("DB", "TMS_DB_DUMP", "TMS", true));
items.add(file("TMS_CONFIG", initExecutorProperties.getTmsConfigDir(), initExecutorProperties.getTmsConfigDir(), "DIR", true));
items.add(file("CP_CONFIG", resolveCpConfigPath(productType), resolveCpConfigPath(productType), "FILE", true));
items.add(file("RECEIVER_LICENSE", resolveReceiverLicensePath(productType), resolveReceiverLicensePath(productType), "FILE", true));
items.add(database("TMS_DB", "TMS", "db/TMS.sql", true));
if (!"DIRECT".equals(productType)) {
// 第一版标准版迁移需要把收发器业务库一起带走,DIRECT 暂时不要求这份库。
items.add(item("DB", "CMEP_DB_DUMP", "CMEP", true));
}
items.add(item("FILE", "CP_CONFIG", resolveCpConfigPath(productType), true));
items.add(item("FILE", "RECEIVER_LICENSE", resolveReceiverLicensePath(productType), true));
if (mqType.startsWith("RABBITMQ")) {
items.add(item("MQ", "RABBITMQ_DEFINITIONS", initExecutorProperties.getRabbitmqAdminCommand(), true));
}
if ("TLQ".equals(mqType) || "RABBITMQ_TLQ".equals(mqType)) {
items.add(item("FILE", "TLQ_LICENSE", resolveTlqLicensePath(productType), true));
}
if ("RABBITMQ_CFMQ".equals(mqType)) {
items.add(item("FILE", "CFMQ_CONFIG", initExecutorProperties.getCfmqTargetDir(), true));
items.add(database("CMEP_DB", "CMEP", "db/CMEP.sql", true));
}
items.add(mqReplay(true));
return items;
}
@@ -63,18 +54,33 @@ public class ResourceDiscoveryServiceImpl implements ResourceDiscoveryService {
return initExecutorProperties.getStandardReceiverLicenseTargetPath();
}
private String resolveTlqLicensePath(String productType) {
if ("DIRECT".equals(productType)) {
return initExecutorProperties.getDirectTlqLicenseTargetPath();
}
return initExecutorProperties.getStandardTlqLicenseTargetPath();
}
private static DiscoveredResourceItem item(String type, String code, String path, boolean required) {
private static DiscoveredResourceItem file(String code, String path, String restorePath, String mode, boolean required) {
DiscoveredResourceItem item = new DiscoveredResourceItem();
item.setResourceType(type);
item.setResourceType("FILE");
item.setResourceCode(code);
item.setResolvedPath(path);
item.setRestorePath(restorePath);
item.setMode(mode);
item.setRequired(required);
return item;
}
private static DiscoveredResourceItem database(String code, String databaseName, String entryPath, boolean required) {
DiscoveredResourceItem item = new DiscoveredResourceItem();
item.setResourceType("DATABASE");
item.setResourceCode(code);
item.setDatabaseName(databaseName);
item.setResolvedPath(databaseName);
item.setEntryPath(entryPath);
item.setRequired(required);
return item;
}
private static DiscoveredResourceItem mqReplay(boolean required) {
DiscoveredResourceItem item = new DiscoveredResourceItem();
item.setResourceType("MQ_REPLAY");
item.setResourceCode("MQ_REPLAY");
item.setEntryPath("mq/mq-restore-context.json");
item.setRequired(required);
return item;
}
@@ -21,7 +21,7 @@ public class ResourceRestoreTaskDetailResponse {
private String currentStepCode;
private Integer currentStepNo;
private String lastHeartbeatAt;
private String runnerStatePath;
private String statusFilePath;
private Boolean orphaned;
private List<String> cleanupWarnings;
private List<String> warnings;
@@ -155,12 +155,12 @@ public class ResourceRestoreTaskDetailResponse {
this.lastHeartbeatAt = lastHeartbeatAt;
}
public String getRunnerStatePath() {
return runnerStatePath;
public String getStatusFilePath() {
return statusFilePath;
}
public void setRunnerStatePath(String runnerStatePath) {
this.runnerStatePath = runnerStatePath;
public void setStatusFilePath(String statusFilePath) {
this.statusFilePath = statusFilePath;
}
public Boolean getOrphaned() {
@@ -0,0 +1,7 @@
package com.cisd.tms.modules.backup.dto.support;
public enum BackupResourceKind {
FILE,
DATABASE,
MQ_REPLAY
}
@@ -0,0 +1,59 @@
package com.cisd.tms.modules.backup.dto.support;
public class ConfiguredFileBackupResource {
private String code;
private String kind = BackupResourceKind.FILE.name();
private String mode = "FILE";
private String path;
private String restorePath;
private boolean required = true;
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getKind() {
return kind;
}
public void setKind(String kind) {
this.kind = kind;
}
public String getMode() {
return mode;
}
public void setMode(String mode) {
this.mode = mode;
}
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
public String getRestorePath() {
return restorePath;
}
public void setRestorePath(String restorePath) {
this.restorePath = restorePath;
}
public boolean isRequired() {
return required;
}
public void setRequired(boolean required) {
this.required = required;
}
}
@@ -5,6 +5,10 @@ public class DiscoveredResourceItem {
private String resourceType;
private String resourceCode;
private String resolvedPath;
private String restorePath;
private String mode;
private String databaseName;
private String entryPath;
private boolean required;
public String getResourceType() {
@@ -31,6 +35,38 @@ public class DiscoveredResourceItem {
this.resolvedPath = resolvedPath;
}
public String getRestorePath() {
return restorePath;
}
public void setRestorePath(String restorePath) {
this.restorePath = restorePath;
}
public String getMode() {
return mode;
}
public void setMode(String mode) {
this.mode = mode;
}
public String getDatabaseName() {
return databaseName;
}
public void setDatabaseName(String databaseName) {
this.databaseName = databaseName;
}
public String getEntryPath() {
return entryPath;
}
public void setEntryPath(String entryPath) {
this.entryPath = entryPath;
}
public boolean isRequired() {
return required;
}
@@ -5,8 +5,7 @@ package com.cisd.tms.modules.backup.enums;
*/
public enum ResourceControlPhase {
TMS_PRECHECK,
HANDOFF_READY,
RUNNER_OWNED,
RECONCILE_PENDING,
JAVA_EXTRACT,
SHELL_APPLY,
DONE
}
@@ -8,8 +8,7 @@ public enum ResourceTaskStatus {
PRECHECKING,
PRECHECK_FAILED,
READY,
HANDOFF,
RUNNING,
APPLYING,
VERIFYING,
SUCCESS,
FAILED,
@@ -0,0 +1,77 @@
package com.cisd.tms.modules.backup.packagex;
import com.cisd.tms.modules.init.entity.InitTaskEntity;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.stereotype.Component;
@Component
public class MqReplayContextBuilder {
private final ObjectMapper objectMapper;
public MqReplayContextBuilder(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public byte[] build(InitTaskEntity task) {
if (task == null || isBlank(task.getInitPlanJson())) {
throw new IllegalArgumentException("initPlanJson is empty");
}
try {
JsonNode root = objectMapper.readTree(task.getInitPlanJson());
JsonNode request = root.path("request");
JsonNode nodes = request.path("nodes");
JsonNode mq = request.path("mq");
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("schemaVersion", "1.0");
payload.put("productType", firstNonBlank(text(root, "productType"), task.getProductType()));
payload.put("mqType", firstNonBlank(text(mq, "mqType"), text(root, "mqType"), task.getMqType()));
payload.put("orgCodeType", text(request, "orgCodeType"));
payload.put("orgCode", firstNonBlank(text(request, "orgCode"), text(root, "orgCode"), task.getOrgCode()));
payload.put("orgNameCn", text(request, "orgNameCn"));
payload.put("orgNameEn", text(request, "orgNameEn"));
payload.put("deployMode", text(request, "deployMode"));
Map<String, Object> nodeValues = new LinkedHashMap<>();
nodeValues.put("node01Ip", text(nodes, "node01Ip"));
nodeValues.put("node02Ip", text(nodes, "node02Ip"));
nodeValues.put("node03Ip", text(nodes, "node03Ip"));
nodeValues.put("node04Ip", text(nodes, "node04Ip"));
payload.put("nodes", nodeValues);
Map<String, Object> mqValues = new LinkedHashMap<>();
mqValues.put("channelUsername", text(mq, "channelUsername"));
mqValues.put("channelPassword", text(mq, "channelPassword"));
mqValues.put("cipsGatewayHost", text(mq, "cipsGatewayHost"));
mqValues.put("cfgZipResourceCode", "");
mqValues.put("cfmqConfigResourceCode", "");
payload.put("mq", mqValues);
return objectMapper.writeValueAsBytes(payload);
} catch (Exception ex) {
throw new IllegalArgumentException("failed to build mq replay context", ex);
}
}
private static String text(JsonNode node, String field) {
JsonNode value = node == null ? null : node.path(field);
return value == null || value.isMissingNode() || value.isNull() ? "" : value.asText("");
}
private static String firstNonBlank(String... values) {
for (String value : values) {
if (!isBlank(value)) {
return value.trim();
}
}
return "";
}
private static boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
}
@@ -37,6 +37,7 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
private final ResourceBackupProperties resourceBackupProperties;
private final ResourceEnvelopeCryptoService resourceEnvelopeCryptoService;
private final DatabaseBackupCollector databaseBackupCollector;
private final MqReplayContextBuilder mqReplayContextBuilder;
private final ObjectMapper objectMapper;
public ResourcePackageServiceImpl(
@@ -44,10 +45,21 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
ResourceEnvelopeCryptoService resourceEnvelopeCryptoService,
DatabaseBackupCollector databaseBackupCollector,
ObjectMapper objectMapper
) {
this(resourceBackupProperties, resourceEnvelopeCryptoService, databaseBackupCollector, new MqReplayContextBuilder(objectMapper), objectMapper);
}
public ResourcePackageServiceImpl(
ResourceBackupProperties resourceBackupProperties,
ResourceEnvelopeCryptoService resourceEnvelopeCryptoService,
DatabaseBackupCollector databaseBackupCollector,
MqReplayContextBuilder mqReplayContextBuilder,
ObjectMapper objectMapper
) {
this.resourceBackupProperties = resourceBackupProperties;
this.resourceEnvelopeCryptoService = resourceEnvelopeCryptoService;
this.databaseBackupCollector = databaseBackupCollector;
this.mqReplayContextBuilder = mqReplayContextBuilder;
this.objectMapper = objectMapper;
}
@@ -225,7 +237,7 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
resourceEntry.put("resolvedPath", item.getResolvedPath());
resourceEntry.put("required", item.isRequired());
int collectedFiles = collectResourceItem(zipOutputStream, item, resourceEntry);
int collectedFiles = collectResourceItem(zipOutputStream, initTask, item, resourceEntry);
if (collectedFiles > 0) {
collectedCount++;
}
@@ -243,18 +255,26 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
private int collectResourceItem(
ZipOutputStream zipOutputStream,
InitTaskEntity initTask,
DiscoveredResourceItem item,
Map<String, Object> resourceEntry
) throws IOException {
if ("DB".equalsIgnoreCase(trim(item.getResourceType()))) {
if ("DATABASE".equalsIgnoreCase(trim(item.getResourceType())) || "DB".equalsIgnoreCase(trim(item.getResourceType()))) {
return collectDatabaseItem(zipOutputStream, item, resourceEntry);
}
if ("MQ_REPLAY".equalsIgnoreCase(trim(item.getResourceType()))) {
return collectMqReplayItem(zipOutputStream, initTask, item, resourceEntry);
}
if (!"FILE".equalsIgnoreCase(trim(item.getResourceType()))) {
assertOptionalResource(item, "RESOURCE_TYPE_NOT_SUPPORTED_YET");
resourceEntry.put("collected", Boolean.FALSE);
resourceEntry.put("skipReason", "RESOURCE_TYPE_NOT_SUPPORTED_YET");
return 0;
}
resourceEntry.put("kind", "FILE");
resourceEntry.put("mode", trim(item.getMode()).isEmpty() ? "FILE" : trim(item.getMode()));
resourceEntry.put("sourcePath", item.getResolvedPath());
resourceEntry.put("restorePath", trim(item.getRestorePath()).isEmpty() ? item.getResolvedPath() : item.getRestorePath());
Path resolvedPath = toResolvedPath(item.getResolvedPath());
if (resolvedPath == null || !Files.exists(resolvedPath)) {
assertOptionalResource(item, "PATH_NOT_FOUND");
@@ -328,8 +348,10 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
fileEntry.put("contentSm3", sm3Hex(content));
resourceEntry.put("collected", Boolean.TRUE);
resourceEntry.put("kind", "DATABASE");
resourceEntry.put("fileCount", 1);
resourceEntry.put("entryPath", entryPath);
resourceEntry.put("database", databaseName);
resourceEntry.put("databaseName", databaseName);
resourceEntry.put("files", List.of(fileEntry));
return 1;
@@ -341,12 +363,28 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
}
}
private int collectMqReplayItem(
ZipOutputStream zipOutputStream,
InitTaskEntity initTask,
DiscoveredResourceItem item,
Map<String, Object> resourceEntry
) throws IOException {
byte[] contextBytes = mqReplayContextBuilder.build(initTask);
String entryPath = trim(item.getEntryPath()).isEmpty() ? "mq/mq-restore-context.json" : trim(item.getEntryPath());
addZipEntry(zipOutputStream, entryPath, contextBytes);
resourceEntry.put("kind", "MQ_REPLAY");
resourceEntry.put("collected", Boolean.TRUE);
resourceEntry.put("entryPath", entryPath);
resourceEntry.put("contentSm3", sm3Hex(contextBytes));
return 1;
}
private DatabaseBackupCollector.CollectedDatabaseDump resolveDatabaseDump(DiscoveredResourceItem item) {
String resourceCode = trim(item.getResourceCode());
if ("TMS_DB_DUMP".equalsIgnoreCase(resourceCode)) {
if ("TMS_DB".equalsIgnoreCase(resourceCode) || "TMS_DB_DUMP".equalsIgnoreCase(resourceCode)) {
return databaseBackupCollector.collectTmsDatabaseDump();
}
if ("CMEP_DB_DUMP".equalsIgnoreCase(resourceCode)) {
if ("CMEP_DB".equalsIgnoreCase(resourceCode) || "CMEP_DB_DUMP".equalsIgnoreCase(resourceCode)) {
return databaseBackupCollector.collectCmepDatabaseDump();
}
return null;
@@ -0,0 +1,43 @@
package com.cisd.tms.modules.backup.packagex;
import java.nio.file.Path;
public class ResourcePayloadExtractionResult {
private Path workDir;
private Path payloadDir;
private Path manifestPath;
private Path restoreContextPath;
public Path getWorkDir() {
return workDir;
}
public void setWorkDir(Path workDir) {
this.workDir = workDir;
}
public Path getPayloadDir() {
return payloadDir;
}
public void setPayloadDir(Path payloadDir) {
this.payloadDir = payloadDir;
}
public Path getManifestPath() {
return manifestPath;
}
public void setManifestPath(Path manifestPath) {
this.manifestPath = manifestPath;
}
public Path getRestoreContextPath() {
return restoreContextPath;
}
public void setRestoreContextPath(Path restoreContextPath) {
this.restoreContextPath = restoreContextPath;
}
}
@@ -0,0 +1,327 @@
package com.cisd.tms.modules.backup.packagex;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.springframework.stereotype.Component;
@Component
public class ResourcePayloadExtractor {
private final ResourcePackageService resourcePackageService;
private final ObjectMapper objectMapper;
private final ResourceBackupProperties properties;
public ResourcePayloadExtractor(
ResourcePackageService resourcePackageService,
ObjectMapper objectMapper,
ResourceBackupProperties properties
) {
this.resourcePackageService = resourcePackageService;
this.objectMapper = objectMapper;
this.properties = properties;
}
public ResourcePayloadExtractionResult extract(Path packagePath, Path workDir) {
Path normalizedWorkDir = workDir.normalize();
Path payloadDir = normalizedWorkDir.resolve("payload").normalize();
ResourcePackageInspectResult inspectResult = resourcePackageService.inspectPackage(packagePath);
byte[] payloadArchiveBytes = Optional.ofNullable(inspectResult.getPayloadArchiveBytes())
.orElseThrow(() -> new IllegalStateException("payload archive bytes missing"));
extractPayloadArchive(payloadArchiveBytes, payloadDir);
Path manifestPath = payloadDir.resolve("payload-manifest.json").normalize();
if (!Files.isRegularFile(manifestPath)) {
throw new IllegalStateException("payload manifest missing: " + manifestPath);
}
Map<String, Object> manifest = readManifest(manifestPath);
validateRestoreTargets(payloadDir, manifest);
writeShellPlans(normalizedWorkDir, manifest);
Path restoreContextPath = writeRestoreContext(packagePath, normalizedWorkDir, payloadDir, manifestPath, manifest);
ResourcePayloadExtractionResult result = new ResourcePayloadExtractionResult();
result.setWorkDir(normalizedWorkDir);
result.setPayloadDir(payloadDir);
result.setManifestPath(manifestPath);
result.setRestoreContextPath(restoreContextPath);
return result;
}
private void extractPayloadArchive(byte[] payloadArchiveBytes, Path payloadDir) {
try {
Files.createDirectories(payloadDir);
try (ZipInputStream zipInputStream = new ZipInputStream(new ByteArrayInputStream(payloadArchiveBytes))) {
ZipEntry entry;
while ((entry = zipInputStream.getNextEntry()) != null) {
Path target = resolveSafePayloadTarget(payloadDir, entry.getName());
if (entry.isDirectory()) {
Files.createDirectories(target);
} else {
Files.createDirectories(target.getParent());
Files.write(target, readAllBytes(zipInputStream));
}
zipInputStream.closeEntry();
}
}
} catch (IOException ex) {
throw new IllegalStateException("failed to extract payload archive", ex);
}
}
private Path resolveSafePayloadTarget(Path payloadDir, String entryName) {
Path target = payloadDir.resolve(entryName).normalize();
if (!target.startsWith(payloadDir)) {
throw new IllegalStateException("payload archive entry escapes payload dir: " + entryName);
}
return target;
}
private Map<String, Object> readManifest(Path manifestPath) {
try {
return objectMapper.readValue(manifestPath.toFile(), new TypeReference<Map<String, Object>>() {
});
} catch (IOException ex) {
throw new IllegalStateException("failed to read payload manifest: " + manifestPath, ex);
}
}
private void validateRestoreTargets(Path payloadDir, Map<String, Object> manifest) {
List<Path> allowedRoots = allowedRestoreRoots();
Object resourcesValue = manifest.get("resources");
if (!(resourcesValue instanceof List<?> resources)) {
return;
}
for (Object resourceObject : resources) {
if (!(resourceObject instanceof Map<?, ?> rawResource)) {
continue;
}
Map<String, Object> resource = castMap(rawResource);
if (!"FILE".equalsIgnoreCase(kindOf(resource))) {
continue;
}
String resourceRestorePath = firstText(resource, "restorePath", "resolvedPath");
validateAllowedRestorePath(resourceRestorePath, allowedRoots);
validateFileEntries(payloadDir, resource, resourceRestorePath, allowedRoots);
}
}
private void writeShellPlans(Path workDir, Map<String, Object> manifest) {
List<String> fileLines = new ArrayList<>();
List<String> databaseLines = new ArrayList<>();
List<String> mqLines = new ArrayList<>();
Object resourcesValue = manifest.get("resources");
if (resourcesValue instanceof List<?> resources) {
for (Object resourceObject : resources) {
if (!(resourceObject instanceof Map<?, ?> rawResource)) {
continue;
}
Map<String, Object> resource = castMap(rawResource);
String kind = kindOf(resource);
if ("FILE".equalsIgnoreCase(kind)) {
appendFilePlanLines(fileLines, resource);
} else if ("DATABASE".equalsIgnoreCase(kind) || "DB".equalsIgnoreCase(kind)) {
String entryPath = firstText(resource, "entryPath");
String databaseName = normalizeDatabaseName(firstText(resource, "databaseName", "database", "resourceCode", "code"));
if (!entryPath.isEmpty()) {
databaseLines.add(entryPath + "\t" + databaseName);
}
} else if ("MQ_REPLAY".equalsIgnoreCase(kind)) {
String entryPath = firstText(resource, "entryPath");
if (!entryPath.isEmpty()) {
mqLines.add(entryPath);
}
}
}
}
try {
Files.writeString(workDir.resolve("restore-files.tsv"), String.join(System.lineSeparator(), fileLines)
+ (fileLines.isEmpty() ? "" : System.lineSeparator()));
Files.writeString(workDir.resolve("restore-databases.tsv"), String.join(System.lineSeparator(), databaseLines)
+ (databaseLines.isEmpty() ? "" : System.lineSeparator()));
Files.writeString(workDir.resolve("restore-mq.tsv"), String.join(System.lineSeparator(), mqLines)
+ (mqLines.isEmpty() ? "" : System.lineSeparator()));
} catch (IOException ex) {
throw new IllegalStateException("failed to write shell restore plans", ex);
}
}
private void appendFilePlanLines(List<String> fileLines, Map<String, Object> resource) {
String resourceRestorePath = firstText(resource, "restorePath", "resolvedPath");
Object filesValue = resource.get("files");
if (!(filesValue instanceof List<?> files)) {
return;
}
for (Object fileObject : files) {
if (!(fileObject instanceof Map<?, ?> rawFile)) {
continue;
}
Map<String, Object> fileEntry = castMap(rawFile);
String entryPath = firstText(fileEntry, "entryPath");
if (entryPath.isEmpty()) {
continue;
}
String restorePath = firstText(fileEntry, "restorePath");
if (restorePath.isEmpty()) {
restorePath = inferFileRestorePath(resource, resourceRestorePath, fileEntry);
}
if (!restorePath.isEmpty()) {
fileLines.add(entryPath + "\t" + restorePath);
}
}
}
private void validateFileEntries(
Path payloadDir,
Map<String, Object> resource,
String resourceRestorePath,
List<Path> allowedRoots
) {
Object filesValue = resource.get("files");
if (!(filesValue instanceof List<?> files)) {
return;
}
for (Object fileObject : files) {
if (!(fileObject instanceof Map<?, ?> rawFile)) {
continue;
}
Map<String, Object> fileEntry = castMap(rawFile);
validatePayloadEntryPath(payloadDir, firstText(fileEntry, "entryPath"));
String fileRestorePath = firstText(fileEntry, "restorePath");
if (fileRestorePath.isEmpty()) {
fileRestorePath = inferFileRestorePath(resource, resourceRestorePath, fileEntry);
}
validateAllowedRestorePath(fileRestorePath, allowedRoots);
}
}
private void validatePayloadEntryPath(Path payloadDir, String entryPath) {
if (entryPath.isEmpty()) {
return;
}
Path sourceFile = payloadDir.resolve(entryPath).normalize();
if (!sourceFile.startsWith(payloadDir)) {
throw new IllegalStateException("payload manifest entry escapes payload dir: " + entryPath);
}
}
private String inferFileRestorePath(Map<String, Object> resource, String resourceRestorePath, Map<String, Object> fileEntry) {
if (resourceRestorePath.isEmpty()) {
return "";
}
String relativeName = firstText(fileEntry, "relativeName");
String mode = firstText(resource, "mode");
if (!relativeName.isEmpty() && "DIR".equalsIgnoreCase(mode)) {
return Path.of(resourceRestorePath).resolve(relativeName).normalize().toString();
}
return resourceRestorePath;
}
private void validateAllowedRestorePath(String restorePath, List<Path> allowedRoots) {
if (restorePath.isEmpty()) {
return;
}
Path target = Path.of(restorePath).normalize().toAbsolutePath();
boolean allowed = allowedRoots.stream().anyMatch(target::startsWith);
if (!allowed) {
throw new IllegalStateException("restore path is not allowed: " + restorePath);
}
}
private List<Path> allowedRestoreRoots() {
List<String> configuredRoots = properties.getAllowedRestoreRoots();
if (configuredRoots == null || configuredRoots.isEmpty()) {
return List.of();
}
List<Path> roots = new ArrayList<>();
for (String configuredRoot : configuredRoots) {
String root = trim(configuredRoot);
if (!root.isEmpty()) {
roots.add(Path.of(root).normalize().toAbsolutePath());
}
}
return roots;
}
private Path writeRestoreContext(
Path packagePath,
Path workDir,
Path payloadDir,
Path manifestPath,
Map<String, Object> manifest
) {
try {
Files.createDirectories(workDir);
Path restoreContextPath = workDir.resolve("restore-context.json").normalize();
Map<String, Object> context = new LinkedHashMap<>();
context.put("schemaVersion", "1.0");
context.put("createdAt", Instant.now().toString());
context.put("packagePath", packagePath.normalize().toString());
context.put("workDir", workDir.toString());
context.put("payloadDir", payloadDir.toString());
context.put("manifestPath", manifestPath.toString());
context.put("payloadManifest", manifest);
objectMapper.writerWithDefaultPrettyPrinter().writeValue(restoreContextPath.toFile(), context);
return restoreContextPath;
} catch (IOException ex) {
throw new IllegalStateException("failed to write restore context", ex);
}
}
private static Map<String, Object> castMap(Map<?, ?> rawMap) {
Map<String, Object> result = new LinkedHashMap<>();
rawMap.forEach((key, value) -> result.put(String.valueOf(key), value));
return result;
}
private static String kindOf(Map<String, Object> resource) {
String kind = firstText(resource, "kind");
if (!kind.isEmpty()) {
return kind;
}
return firstText(resource, "resourceType");
}
private static String firstText(Map<String, Object> map, String... keys) {
for (String key : keys) {
Object value = map.get(key);
if (value != null) {
String text = trim(String.valueOf(value));
if (!text.isEmpty() && !"null".equalsIgnoreCase(text)) {
return text;
}
}
}
return "";
}
private static String normalizeDatabaseName(String value) {
if ("TMS_DB".equalsIgnoreCase(value) || "TMS_DB_DUMP".equalsIgnoreCase(value)) {
return "TMS";
}
if ("CMEP_DB".equalsIgnoreCase(value) || "CMEP_DB_DUMP".equalsIgnoreCase(value)) {
return "CMEP";
}
return value;
}
private static byte[] readAllBytes(InputStream inputStream) throws IOException {
return inputStream.readAllBytes();
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
}
@@ -0,0 +1,108 @@
package com.cisd.tms.modules.backup.restore;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class LightweightRestoreApplierLauncher {
private final ResourceBackupProperties properties;
private final RestoreCommandInvoker invoker;
private final String datasourceUrl;
private final String datasourceUsername;
private final String datasourcePassword;
public LightweightRestoreApplierLauncher(
ResourceBackupProperties properties,
RestoreCommandInvoker invoker,
@Value("${spring.datasource.url:}") String datasourceUrl,
@Value("${spring.datasource.username:}") String datasourceUsername,
@Value("${spring.datasource.password:}") String datasourcePassword
) {
this.properties = properties;
this.invoker = invoker;
this.datasourceUrl = datasourceUrl;
this.datasourceUsername = datasourceUsername;
this.datasourcePassword = datasourcePassword;
}
public void launch(Path workDir) {
Path normalizedWorkDir = workDir.toAbsolutePath().normalize();
writeRestoreEnv(normalizedWorkDir);
Path logFile = normalizedWorkDir.resolve("restore-launch.log");
List<String> command = List.of(
"/bin/bash",
properties.getRestoreApplyScriptPath(),
"--work-dir",
normalizedWorkDir.toString()
);
int exitCode = invoker.run(command, logFile, normalizedWorkDir);
if (exitCode != 0) {
throw new IllegalStateException("failed to launch lightweight restore apply script, exitCode=" + exitCode);
}
}
private void writeRestoreEnv(Path workDir) {
try {
Files.createDirectories(workDir);
Map<String, String> values = new LinkedHashMap<>();
MysqlTarget target = parseMysqlTarget(datasourceUrl);
values.put("TMS_SCRIPT_PATH", trim(properties.getTmsScriptPath()));
values.put("DB_RESTORE_SCRIPT_PATH", trim(properties.getRestoreDbScriptPath()));
values.put("MQ_REPLAY_SCRIPT_PATH", trim(properties.getMqReplayScriptPath()));
values.put("MYSQL_PATH", trim(properties.getMysqlPath()).isEmpty() ? "mysql" : trim(properties.getMysqlPath()));
values.put("DB_HOST", target.host());
values.put("DB_PORT", target.port());
values.put("DB_USER", trim(datasourceUsername));
values.put("DB_PASSWORD", trim(datasourcePassword));
values.put("HEALTH_CHECK_URL", trim(properties.getHealthCheckUrl()));
values.put("HEALTH_CHECK_TIMEOUT_SECONDS", String.valueOf(properties.getHealthCheckTimeoutSeconds()));
StringBuilder builder = new StringBuilder("# generated by TMS resource restore\n");
for (Map.Entry<String, String> entry : values.entrySet()) {
builder.append("export ")
.append(entry.getKey())
.append("=")
.append(quoteShell(entry.getValue()))
.append(System.lineSeparator());
}
Files.writeString(workDir.resolve("restore-env.sh"), builder.toString());
} catch (IOException ex) {
throw new IllegalStateException("failed to write lightweight restore env", ex);
}
}
private static MysqlTarget parseMysqlTarget(String datasourceUrl) {
String url = trim(datasourceUrl);
if (url.startsWith("jdbc:mysql://")) {
try {
URI uri = URI.create(url.substring("jdbc:".length()).split("\\?", 2)[0]);
String host = uri.getHost() == null ? "127.0.0.1" : uri.getHost();
int port = uri.getPort() <= 0 ? 3306 : uri.getPort();
return new MysqlTarget(host, String.valueOf(port));
} catch (IllegalArgumentException ignore) {
return new MysqlTarget("", "");
}
}
return new MysqlTarget("", "");
}
private static String quoteShell(String value) {
return "'" + trim(value).replace("'", "'\\''") + "'";
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
private record MysqlTarget(String host, String port) {
}
}
@@ -1,4 +1,4 @@
package com.cisd.tms.modules.backup.runner;
package com.cisd.tms.modules.backup.restore;
import java.io.IOException;
import java.nio.file.Files;
@@ -7,7 +7,7 @@ import java.util.List;
import org.springframework.stereotype.Component;
@Component
public class ProcessBuilderRunnerCommandInvoker implements RunnerCommandInvoker {
public class ProcessBuilderRestoreCommandInvoker implements RestoreCommandInvoker {
@Override
public int run(List<String> command, Path logFile, Path workingDirectory) {
@@ -27,10 +27,10 @@ public class ProcessBuilderRunnerCommandInvoker implements RunnerCommandInvoker
Process process = pb.start();
return process.waitFor();
} catch (IOException ex) {
throw new IllegalStateException("failed to start runner command: " + String.join(" ", command), ex);
throw new IllegalStateException("failed to start restore command: " + String.join(" ", command), ex);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IllegalStateException("runner command interrupted: " + String.join(" ", command), ex);
throw new IllegalStateException("restore command interrupted: " + String.join(" ", command), ex);
}
}
}
@@ -1,9 +1,9 @@
package com.cisd.tms.modules.backup.runner;
package com.cisd.tms.modules.backup.restore;
import java.nio.file.Path;
import java.util.List;
public interface RunnerCommandInvoker {
public interface RestoreCommandInvoker {
int run(List<String> command, Path logFile, Path workingDirectory);
}
@@ -0,0 +1,82 @@
package com.cisd.tms.modules.backup.restore;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import org.springframework.stereotype.Component;
@Component
public class RestoreStatusFileReader {
private static final int MAX_LOG_BYTES = 64 * 1024;
private final ObjectMapper objectMapper;
public RestoreStatusFileReader(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public RestoreStatusSnapshot read(Path workDir) {
if (workDir == null) {
return empty();
}
Path statusFile = workDir.resolve("status.json").normalize();
Path logFile = workDir.resolve("restore.log").normalize();
Map<String, Object> status = readStatus(statusFile);
return new RestoreStatusSnapshot(
text(status.get("status")),
text(status.get("phase")),
text(status.get("message")),
text(status.get("updatedAt")),
readTail(logFile)
);
}
private Map<String, Object> readStatus(Path statusFile) {
if (!Files.isRegularFile(statusFile)) {
return Map.of();
}
try {
return objectMapper.readValue(statusFile.toFile(), new TypeReference<Map<String, Object>>() {
});
} catch (IOException ex) {
throw new IllegalStateException("failed to read restore status file: " + statusFile, ex);
}
}
private String readTail(Path logFile) {
if (!Files.isRegularFile(logFile)) {
return "";
}
try {
byte[] bytes = Files.readAllBytes(logFile);
if (bytes.length <= MAX_LOG_BYTES) {
return new String(bytes, StandardCharsets.UTF_8);
}
return new String(bytes, bytes.length - MAX_LOG_BYTES, MAX_LOG_BYTES, StandardCharsets.UTF_8);
} catch (IOException ex) {
throw new IllegalStateException("failed to read restore log file: " + logFile, ex);
}
}
private static RestoreStatusSnapshot empty() {
return new RestoreStatusSnapshot("", "", "", "", "");
}
private static String text(Object value) {
return value == null ? "" : String.valueOf(value);
}
public record RestoreStatusSnapshot(
String status,
String phase,
String message,
String updatedAt,
String log
) {
}
}
@@ -1,111 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class ConsistencyVerifyRunnerStepExecutor implements RunnerStepExecutor {
private static final List<String> REQUIRED_SUCCESS_STEPS = List.of(
"RESTORE_FILES",
"RESTORE_DATABASES",
"START_SERVICES"
);
private final ResourceBackupProperties resourceBackupProperties;
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
@Autowired
public ConsistencyVerifyRunnerStepExecutor(
ResourceBackupProperties resourceBackupProperties,
ObjectMapper objectMapper
) {
this(resourceBackupProperties, objectMapper, HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build());
}
ConsistencyVerifyRunnerStepExecutor(
ResourceBackupProperties resourceBackupProperties,
ObjectMapper objectMapper,
HttpClient httpClient
) {
this.resourceBackupProperties = resourceBackupProperties;
this.objectMapper = objectMapper;
this.httpClient = httpClient;
}
@Override
public boolean supports(String stepCode) {
return "CONSISTENCY_VERIFY".equals(stepCode);
}
@Override
public RunnerStepExecutionResult execute(RunnerStepContext context) {
Path stepOutputDir = context.taskDir().resolve("step-output");
for (String requiredStep : REQUIRED_SUCCESS_STEPS) {
assertStepSucceeded(stepOutputDir, requiredStep);
}
verifyHealthEndpoint();
return RunnerStepExecutionResult.success("required steps succeeded and health endpoint is UP");
}
private void assertStepSucceeded(Path stepOutputDir, String stepCode) {
try {
Path stepFile = resolveRequiredStepOutput(stepOutputDir, stepCode);
Map<String, Object> payload = objectMapper.readValue(stepFile.toFile(), new TypeReference<Map<String, Object>>() {
});
String result = String.valueOf(payload.get("result"));
if (!"SUCCESS".equalsIgnoreCase(result)) {
throw new IllegalStateException("required step is not successful: " + stepCode);
}
} catch (IOException ex) {
throw new IllegalStateException("failed to read step output for " + stepCode, ex);
}
}
private Path resolveRequiredStepOutput(Path stepOutputDir, String stepCode) throws IOException {
if (!Files.isDirectory(stepOutputDir)) {
throw new IllegalStateException("step output directory missing: " + stepOutputDir);
}
try (var pathStream = Files.list(stepOutputDir)) {
return pathStream
.filter(path -> path.getFileName().toString().endsWith("-" + stepCode + ".json"))
.findFirst()
.orElseThrow(() -> new IllegalStateException("step output missing for " + stepCode));
}
}
private void verifyHealthEndpoint() {
String healthUrl = resourceBackupProperties.getHealthCheckUrl();
try {
HttpRequest request = HttpRequest.newBuilder(URI.create(healthUrl))
.timeout(Duration.ofSeconds(resourceBackupProperties.getHealthCheckTimeoutSeconds()))
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200 || !response.body().contains("\"status\":\"UP\"")) {
throw new IllegalStateException("health endpoint is not UP: statusCode=" + response.statusCode());
}
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IllegalStateException("health verification interrupted", ex);
} catch (Exception ex) {
throw new IllegalStateException("failed to verify health endpoint: " + healthUrl, ex);
}
}
}
@@ -1,135 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class DatabaseRestoreRunnerStepExecutor implements RunnerStepExecutor {
private final ResourceBackupProperties resourceBackupProperties;
private final RunnerCommandInvoker runnerCommandInvoker;
private final String datasourceUrl;
private final String datasourceUsername;
private final String datasourcePassword;
public DatabaseRestoreRunnerStepExecutor(
ResourceBackupProperties resourceBackupProperties,
RunnerCommandInvoker runnerCommandInvoker,
@Value("${spring.datasource.url:}") String datasourceUrl,
@Value("${spring.datasource.username:}") String datasourceUsername,
@Value("${spring.datasource.password:}") String datasourcePassword
) {
this.resourceBackupProperties = resourceBackupProperties;
this.runnerCommandInvoker = runnerCommandInvoker;
this.datasourceUrl = datasourceUrl;
this.datasourceUsername = datasourceUsername;
this.datasourcePassword = datasourcePassword;
}
@Override
public boolean supports(String stepCode) {
return "RESTORE_DATABASES".equals(stepCode);
}
@Override
public RunnerStepExecutionResult execute(RunnerStepContext context) {
if (!supports(context.stepCode())) {
throw new IllegalArgumentException("unsupported runner step: " + context.stepCode());
}
List<String> restoredDatabases = new ArrayList<>();
restoreDatabase(context, "TMS.sql", resourceBackupProperties.getTmsDatabaseName(), "TMS", true, restoredDatabases);
// 标准版第一版还需要恢复收发器业务库;DIRECT 当前没有这份库时允许跳过。
restoreDatabase(context, "CMEP.sql", resourceBackupProperties.getCmepDatabaseName(), "CMEP", false, restoredDatabases);
return RunnerStepExecutionResult.success("restored databases " + String.join(",", restoredDatabases));
}
private void restoreDatabase(
RunnerStepContext context,
String sqlFileName,
String configuredDatabaseName,
String fallbackDatabaseName,
boolean required,
List<String> restoredDatabases
) {
Path sqlFile = context.taskDir().resolve("staging").resolve("payload").resolve("db").resolve(sqlFileName).normalize();
if (!Files.isRegularFile(sqlFile)) {
if (required) {
throw new IllegalStateException("restore database sql file missing: " + sqlFile);
}
return;
}
MysqlTarget target = resolveTarget(configuredDatabaseName, fallbackDatabaseName);
List<String> command = new ArrayList<>();
command.add("/bin/bash");
command.add(resourceBackupProperties.getRestoreDbScriptPath());
command.add("--mysql-path");
command.add(resourceBackupProperties.getMysqlPath());
command.add("--host");
command.add(target.host());
command.add("--port");
command.add(String.valueOf(target.port()));
command.add("--username");
command.add(target.username());
command.add("--password");
command.add(target.password());
command.add("--db-name");
command.add(target.databaseName());
command.add("--sql-file");
command.add(sqlFile.toString());
int exitCode = runnerCommandInvoker.run(command, resolveLogFile(context), context.taskDir());
if (exitCode != 0) {
throw new IllegalStateException("runner step RESTORE_DATABASES failed for " + target.databaseName() + ", exitCode=" + exitCode);
}
restoredDatabases.add(target.databaseName());
}
private MysqlTarget resolveTarget(String configuredDatabaseName, String fallbackDatabaseName) {
String url = trim(datasourceUrl);
if (url.isEmpty()) {
throw new IllegalStateException("spring.datasource.url is required for database restore");
}
if (!url.startsWith("jdbc:mysql://")) {
throw new IllegalStateException("unsupported datasource url for restore: " + url);
}
try {
String normalizedUrl = url.substring("jdbc:".length());
String[] segments = normalizedUrl.split("\\?", 2);
URI uri = URI.create(segments[0]);
String databaseName = trim(configuredDatabaseName);
if (databaseName.isEmpty()) {
String path = trim(uri.getPath());
databaseName = path.startsWith("/") ? path.substring(1) : path;
}
if (databaseName.isEmpty()) {
databaseName = fallbackDatabaseName;
}
return new MysqlTarget(
uri.getHost() == null ? "127.0.0.1" : uri.getHost(),
uri.getPort() <= 0 ? 3306 : uri.getPort(),
trim(datasourceUsername),
trim(datasourcePassword),
databaseName
);
} catch (IllegalArgumentException ex) {
throw new IllegalStateException("invalid datasource url for restore: " + url, ex);
}
}
private Path resolveLogFile(RunnerStepContext context) {
return context.taskDir().resolve("steps").resolve(String.format("%02d-%s.log", context.stepNo(), context.stepCode()));
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
private record MysqlTarget(String host, int port, String username, String password, String databaseName) {
}
}
@@ -1,198 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.packagex.ResourcePackageInspectResult;
import com.cisd.tms.modules.backup.packagex.ResourcePackageService;
import com.cisd.tms.modules.backup.runner.model.ResourceRestoreHandoffSnapshot;
import com.cisd.tms.modules.backup.support.file.HandoffFileStore;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.springframework.stereotype.Component;
@Component
public class PayloadArchiveRunnerStepExecutor implements RunnerStepExecutor {
private static final Set<String> RESTORE_FILE_RESOURCE_CODES =
Set.of("TMS_CONFIG", "CP_CONFIG", "RECEIVER_LICENSE");
private final HandoffFileStore handoffFileStore;
private final ResourcePackageService resourcePackageService;
private final ObjectMapper objectMapper;
public PayloadArchiveRunnerStepExecutor(
HandoffFileStore handoffFileStore,
ResourcePackageService resourcePackageService,
ObjectMapper objectMapper
) {
this.handoffFileStore = handoffFileStore;
this.resourcePackageService = resourcePackageService;
this.objectMapper = objectMapper;
}
@Override
public boolean supports(String stepCode) {
return "RESTORE_FILES".equals(stepCode);
}
@Override
public RunnerStepExecutionResult execute(RunnerStepContext context) {
if ("RESTORE_FILES".equals(context.stepCode())) {
return restoreFiles(context);
}
throw new IllegalArgumentException("unsupported runner step: " + context.stepCode());
}
private RunnerStepExecutionResult restoreFiles(RunnerStepContext context) {
ResourceRestoreHandoffSnapshot handoff = readRequiredHandoff(context.handoffFile());
Path packagePath = resolveRequiredPackagePath(handoff);
ResourcePackageInspectResult inspectResult = resourcePackageService.inspectPackage(packagePath);
byte[] payloadArchiveBytes = Optional.ofNullable(inspectResult.getPayloadArchiveBytes())
.orElseThrow(() -> new IllegalStateException("payload archive bytes missing"));
Path payloadDir = context.taskDir().resolve("staging").resolve("payload").normalize();
extractPayloadArchive(payloadArchiveBytes, payloadDir);
Path payloadManifestFile = payloadDir.resolve("payload-manifest.json");
int resourceCount = readResourceCount(payloadManifestFile);
int restoredFileCount = restoreSupportedFiles(payloadDir, payloadManifestFile);
return RunnerStepExecutionResult.success(
"payload extracted to staging, resourceCount=" + resourceCount + ", restoredFileCount=" + restoredFileCount
);
}
private ResourceRestoreHandoffSnapshot readRequiredHandoff(Path handoffFile) {
return handoffFileStore.read(handoffFile)
.orElseThrow(() -> new IllegalStateException("restore handoff file not found: " + handoffFile));
}
private Path resolveRequiredPackagePath(ResourceRestoreHandoffSnapshot handoff) {
String packagePath = trim(handoff.getPackagePath());
if (packagePath.isEmpty()) {
throw new IllegalStateException("restore handoff packagePath is empty");
}
Path resolved = Path.of(packagePath).normalize();
if (!Files.isRegularFile(resolved)) {
throw new IllegalStateException("restore package file not found: " + resolved);
}
return resolved;
}
private void extractPayloadArchive(byte[] payloadArchiveBytes, Path payloadDir) {
try {
Files.createDirectories(payloadDir);
try (ZipInputStream zipInputStream = new ZipInputStream(new ByteArrayInputStream(payloadArchiveBytes))) {
ZipEntry entry;
while ((entry = zipInputStream.getNextEntry()) != null) {
Path target = resolveSafePayloadTarget(payloadDir, entry.getName());
if (entry.isDirectory()) {
Files.createDirectories(target);
continue;
}
Files.createDirectories(target.getParent());
// 备份包来自外部输入,即使已经验签,也要防御 zip-slip,确保只落到 staging/payload 下。
Files.write(target, readAllBytes(zipInputStream));
}
}
} catch (IOException ex) {
throw new IllegalStateException("failed to extract payload archive", ex);
}
}
private Path resolveSafePayloadTarget(Path payloadDir, String entryName) {
Path target = payloadDir.resolve(entryName).normalize();
if (!target.startsWith(payloadDir)) {
throw new IllegalStateException("payload archive entry escapes staging dir: " + entryName);
}
return target;
}
private int readResourceCount(Path manifestFile) {
try {
Map<String, Object> manifest = objectMapper.readValue(manifestFile.toFile(), new TypeReference<Map<String, Object>>() {
});
Object resourcesValue = manifest.get("resources");
if (resourcesValue instanceof List<?> resources) {
return resources.size();
}
return 0;
} catch (IOException ex) {
throw new IllegalStateException("failed to read payload manifest: " + manifestFile, ex);
}
}
private int restoreSupportedFiles(Path payloadDir, Path manifestFile) {
try {
Map<String, Object> manifest = objectMapper.readValue(manifestFile.toFile(), new TypeReference<Map<String, Object>>() {
});
Object resourcesValue = manifest.get("resources");
if (!(resourcesValue instanceof List<?> resources)) {
return 0;
}
int restoredFileCount = 0;
for (Object resourceObject : resources) {
if (!(resourceObject instanceof Map<?, ?> rawResource)) {
continue;
}
Map<String, Object> resource = castMap(rawResource);
String resourceCode = String.valueOf(resource.get("resourceCode"));
if (!RESTORE_FILE_RESOURCE_CODES.contains(resourceCode)) {
continue;
}
Object filesValue = resource.get("files");
if (!(filesValue instanceof List<?> rawFiles)) {
continue;
}
for (Object fileObject : rawFiles) {
if (!(fileObject instanceof Map<?, ?> rawFile)) {
continue;
}
Map<String, Object> fileEntry = castMap(rawFile);
Path sourceFile = payloadDir.resolve(String.valueOf(fileEntry.get("entryPath"))).normalize();
if (!sourceFile.startsWith(payloadDir) || !Files.isRegularFile(sourceFile)) {
throw new IllegalStateException("restored file source missing: " + sourceFile);
}
Path targetFile = resolveTargetFile(resourceCode, String.valueOf(resource.get("resolvedPath")), String.valueOf(fileEntry.get("relativeName")));
Files.createDirectories(targetFile.getParent());
// 第一版只回写最关键的配置/license 文件,不做目录合并或增量策略。
Files.copy(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
restoredFileCount++;
}
}
return restoredFileCount;
} catch (IOException ex) {
throw new IllegalStateException("failed to restore payload files", ex);
}
}
private Path resolveTargetFile(String resourceCode, String resolvedPath, String relativeName) {
Path targetPath = Path.of(resolvedPath).normalize();
if ("TMS_CONFIG".equals(resourceCode)) {
return targetPath.resolve(relativeName).normalize();
}
return targetPath;
}
private static Map<String, Object> castMap(Map<?, ?> rawMap) {
Map<String, Object> result = new java.util.LinkedHashMap<>();
rawMap.forEach((key, value) -> result.put(String.valueOf(key), value));
return result;
}
private static byte[] readAllBytes(InputStream inputStream) throws IOException {
return inputStream.readAllBytes();
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
}
@@ -1,41 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import java.nio.file.Path;
import java.util.List;
import org.springframework.stereotype.Component;
@Component
public class ProcessResourceRestoreRunnerLauncher implements ResourceRestoreRunnerLauncher {
private final ResourceBackupProperties resourceBackupProperties;
private final RunnerCommandInvoker runnerCommandInvoker;
public ProcessResourceRestoreRunnerLauncher(
ResourceBackupProperties resourceBackupProperties,
RunnerCommandInvoker runnerCommandInvoker
) {
this.resourceBackupProperties = resourceBackupProperties;
this.runnerCommandInvoker = runnerCommandInvoker;
}
@Override
public void launch(Path handoffFile) {
if (!resourceBackupProperties.getRunnerAutoLaunchEnabled()) {
return;
}
Path normalizedHandoffFile = handoffFile.normalize().toAbsolutePath();
Path taskDir = normalizedHandoffFile.getParent();
Path launcherLog = taskDir.resolve("runner-launch.log");
List<String> command = List.of(
"/bin/bash",
resourceBackupProperties.getRunnerLaunchScriptPath(),
"--handoff-file",
normalizedHandoffFile.toString()
);
int exitCode = runnerCommandInvoker.run(command, launcherLog, taskDir);
if (exitCode != 0) {
throw new IllegalStateException("failed to launch external restore runner, exitCode=" + exitCode);
}
}
}
@@ -1,156 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.runner.model.ResourceRestoreHandoffSnapshot;
import com.cisd.tms.modules.backup.support.file.HandoffFileStore;
import com.cisd.tms.modules.backup.support.file.ResourceRestoreStateFileStore;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.springframework.stereotype.Component;
@Component
public class ResourceRestoreRunner {
private static final String INITIAL_STEP_CODE = "RESTORE_LOCK";
private static final int INITIAL_STEP_NO = 1;
private final HandoffFileStore handoffFileStore;
private final ResourceRestoreStateFileStore resourceRestoreStateFileStore;
private final ObjectMapper objectMapper;
private final List<RunnerStepExecutor> runnerStepExecutors;
public ResourceRestoreRunner(
HandoffFileStore handoffFileStore,
ResourceRestoreStateFileStore resourceRestoreStateFileStore,
ObjectMapper objectMapper,
List<RunnerStepExecutor> runnerStepExecutors
) {
this.handoffFileStore = handoffFileStore;
this.resourceRestoreStateFileStore = resourceRestoreStateFileStore;
this.objectMapper = objectMapper;
this.runnerStepExecutors = runnerStepExecutors;
}
public void run(Path handoffFile) {
ResourceRestoreHandoffSnapshot handoff = handoffFileStore.read(handoffFile)
.orElseThrow(() -> new IllegalStateException("restore handoff file not found: " + handoffFile));
Path stateFile = Path.of(handoff.getRunnerStatePath()).normalize();
String runnerInstanceId = nextRunnerInstanceId();
long runnerPid = ProcessHandle.current().pid();
resourceRestoreStateFileStore.claimOwnership(stateFile, runnerInstanceId, runnerPid, INITIAL_STEP_CODE, INITIAL_STEP_NO);
List<String> restorePlan = readRestorePlan(handoff.getRestorePlanJson());
try {
for (int i = 0; i < restorePlan.size(); i++) {
String stepCode = restorePlan.get(i);
int stepNo = i + 1;
if (INITIAL_STEP_CODE.equals(stepCode)) {
continue;
}
resourceRestoreStateFileStore.markStepRunning(stateFile, stepCode, stepNo, runnerInstanceId);
RunnerStepExecutor executor = resolveExecutor(stepCode);
RunnerStepContext context = new RunnerStepContext(
handoff.getTaskId(),
stepCode,
stepNo,
handoffFile.getParent(),
handoffFile,
stateFile
);
RunnerStepExecutionResult result = executor.execute(context);
writeStepOutput(context, result);
resourceRestoreStateFileStore.touchHeartbeat(stateFile, runnerInstanceId);
}
String lastStepCode = restorePlan.isEmpty() ? INITIAL_STEP_CODE : restorePlan.get(restorePlan.size() - 1);
int lastStepNo = restorePlan.isEmpty() ? INITIAL_STEP_NO : restorePlan.size();
resourceRestoreStateFileStore.markReconcilePending(stateFile, "SUCCESS", null, null, lastStepCode, lastStepNo, runnerInstanceId);
} catch (RuntimeException ex) {
int failedStepNo = detectFailedStepNo(handoff, stateFile);
String failedStepCode = detectFailedStepCode(handoff, stateFile);
writeStepFailureOutput(handoffFile.getParent(), failedStepCode, failedStepNo, ex);
resourceRestoreStateFileStore.markReconcilePending(
stateFile,
"FAILED",
"RUNNER_EXECUTION_FAILED",
ex.getMessage(),
failedStepCode,
failedStepNo,
runnerInstanceId
);
throw ex;
}
}
private String nextRunnerInstanceId() {
return "RRUN-" + UUID.randomUUID();
}
private List<String> readRestorePlan(String restorePlanJson) {
if (restorePlanJson == null || restorePlanJson.isBlank()) {
return List.of(INITIAL_STEP_CODE);
}
try {
return objectMapper.readValue(restorePlanJson, new TypeReference<List<String>>() {
});
} catch (Exception ex) {
throw new IllegalStateException("failed to parse restore plan json", ex);
}
}
private RunnerStepExecutor resolveExecutor(String stepCode) {
return runnerStepExecutors.stream()
.filter(executor -> executor.supports(stepCode))
.findFirst()
.orElseThrow(() -> new IllegalStateException("no runner step executor for step: " + stepCode));
}
private int detectFailedStepNo(ResourceRestoreHandoffSnapshot handoff, Path stateFile) {
return resourceRestoreStateFileStore.read(stateFile)
.map(snapshot -> snapshot.getCurrentStepNo() == null ? INITIAL_STEP_NO : snapshot.getCurrentStepNo())
.orElse(INITIAL_STEP_NO);
}
private String detectFailedStepCode(ResourceRestoreHandoffSnapshot handoff, Path stateFile) {
return resourceRestoreStateFileStore.read(stateFile)
.map(snapshot -> snapshot.getCurrentStepCode() == null || snapshot.getCurrentStepCode().isBlank()
? INITIAL_STEP_CODE
: snapshot.getCurrentStepCode())
.orElse(INITIAL_STEP_CODE);
}
private void writeStepOutput(RunnerStepContext context, RunnerStepExecutionResult result) {
try {
Path outputDir = context.taskDir().resolve("step-output");
Files.createDirectories(outputDir);
Path outputFile = outputDir.resolve(String.format("%02d-%s.json", context.stepNo(), context.stepCode()));
Map<String, Object> json = new LinkedHashMap<>();
json.put("stepCode", context.stepCode());
json.put("result", result.getResult());
json.put("summary", result.getSummary());
objectMapper.writeValue(outputFile.toFile(), json);
} catch (IOException ex) {
throw new IllegalStateException("failed to write step output for " + context.stepCode(), ex);
}
}
private void writeStepFailureOutput(Path taskDir, String stepCode, int stepNo, RuntimeException ex) {
try {
Path outputDir = taskDir.resolve("step-output");
Files.createDirectories(outputDir);
Path outputFile = outputDir.resolve(String.format("%02d-%s.json", stepNo, stepCode));
Map<String, Object> json = new LinkedHashMap<>();
json.put("stepCode", stepCode);
json.put("result", "FAILED");
json.put("summary", ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage());
objectMapper.writeValue(outputFile.toFile(), json);
} catch (IOException ioException) {
throw new IllegalStateException("failed to write failed step output for " + stepCode, ioException);
}
}
}
@@ -1,8 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import java.nio.file.Path;
public interface ResourceRestoreRunnerLauncher {
void launch(Path handoffFile);
}
@@ -1,38 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.TmsApplication;
import java.nio.file.Path;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.env.Environment;
public final class ResourceRestoreRunnerMain {
private ResourceRestoreRunnerMain() {
}
public static void main(String[] args) {
ConfigurableApplicationContext context = new SpringApplicationBuilder(TmsApplication.class)
.web(WebApplicationType.NONE)
.logStartupInfo(false)
.run(args);
int exitCode = 0;
try {
Environment environment = context.getEnvironment();
String handoffFile = environment.getProperty("tms.backup.runner-handoff-file", "");
if (handoffFile == null || handoffFile.isBlank()) {
throw new IllegalStateException("tms.backup.runner-handoff-file is required");
}
context.getBean(ResourceRestoreRunner.class).run(Path.of(handoffFile));
} catch (RuntimeException ex) {
exitCode = 1;
throw ex;
} finally {
final int finalExitCode = exitCode;
int springExitCode = SpringApplication.exit(context, () -> finalExitCode);
System.exit(springExitCode);
}
}
}
@@ -1,60 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity;
import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository;
import com.cisd.tms.modules.backup.support.file.ResourceRestoreStateFileStore;
import java.time.LocalDateTime;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class RestoreStateReconciler {
private final ResourceRestoreTaskRepository resourceRestoreTaskRepository;
private final ResourceRestoreStateFileStore resourceRestoreStateFileStore;
private final Supplier<LocalDateTime> nowSupplier;
@Autowired
public RestoreStateReconciler(
ResourceRestoreTaskRepository resourceRestoreTaskRepository,
ResourceRestoreStateFileStore resourceRestoreStateFileStore
) {
this(resourceRestoreTaskRepository, resourceRestoreStateFileStore, LocalDateTime::now);
}
RestoreStateReconciler(
ResourceRestoreTaskRepository resourceRestoreTaskRepository,
ResourceRestoreStateFileStore resourceRestoreStateFileStore,
Supplier<LocalDateTime> nowSupplier
) {
this.resourceRestoreTaskRepository = resourceRestoreTaskRepository;
this.resourceRestoreStateFileStore = resourceRestoreStateFileStore;
this.nowSupplier = nowSupplier;
}
public void reconcileOnce() {
for (ResourceRestoreTaskEntity entity : resourceRestoreTaskRepository.findAll()) {
if (entity.getRunnerStatePath() == null || entity.getRunnerStatePath().isBlank()) {
continue;
}
java.nio.file.Path stateFile = java.nio.file.Path.of(entity.getRunnerStatePath()).normalize();
resourceRestoreStateFileStore.read(stateFile).ifPresent(snapshot -> {
if (!"RECONCILE_PENDING".equals(snapshot.getControlPhase())) {
return;
}
entity.setStatus(snapshot.getStatus());
entity.setErrorCode(blankToNull(snapshot.getErrorCode()));
entity.setErrorMessage(blankToNull(snapshot.getErrorMessage()));
entity.setFinishTime(nowSupplier.get());
entity.setUpdateTime(nowSupplier.get());
resourceRestoreTaskRepository.update(entity);
resourceRestoreStateFileStore.markDone(stateFile);
});
}
}
private static String blankToNull(String value) {
return value == null || value.isBlank() ? null : value;
}
}
@@ -1,64 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity;
import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository;
import com.cisd.tms.modules.backup.support.file.ResourceRestoreStateFileStore;
import java.nio.file.Path;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class RunnerStallDetector {
private final ResourceRestoreTaskRepository resourceRestoreTaskRepository;
private final ResourceRestoreStateFileStore resourceRestoreStateFileStore;
private final ResourceBackupProperties resourceBackupProperties;
private final Supplier<LocalDateTime> nowSupplier;
@Autowired
public RunnerStallDetector(
ResourceRestoreTaskRepository resourceRestoreTaskRepository,
ResourceRestoreStateFileStore resourceRestoreStateFileStore,
ResourceBackupProperties resourceBackupProperties
) {
this(resourceRestoreTaskRepository, resourceRestoreStateFileStore, resourceBackupProperties, LocalDateTime::now);
}
RunnerStallDetector(
ResourceRestoreTaskRepository resourceRestoreTaskRepository,
ResourceRestoreStateFileStore resourceRestoreStateFileStore,
ResourceBackupProperties resourceBackupProperties,
Supplier<LocalDateTime> nowSupplier
) {
this.resourceRestoreTaskRepository = resourceRestoreTaskRepository;
this.resourceRestoreStateFileStore = resourceRestoreStateFileStore;
this.resourceBackupProperties = resourceBackupProperties;
this.nowSupplier = nowSupplier;
}
public void detectOnce() {
for (ResourceRestoreTaskEntity entity : resourceRestoreTaskRepository.findAll()) {
if (entity.getRunnerStatePath() == null || entity.getRunnerStatePath().isBlank()) {
continue;
}
Path stateFile = Path.of(entity.getRunnerStatePath()).normalize();
resourceRestoreStateFileStore.read(stateFile).ifPresent(snapshot -> {
if (!"RUNNER_OWNED".equals(snapshot.getControlPhase())) {
return;
}
if (snapshot.getLastHeartbeatAt() == null || snapshot.getLastHeartbeatAt().isBlank()) {
return;
}
LocalDateTime heartbeat = LocalDateTime.parse(snapshot.getLastHeartbeatAt());
if (Duration.between(heartbeat, nowSupplier.get()).getSeconds() <= resourceBackupProperties.getRunnerHeartbeatTimeoutSeconds()) {
return;
}
resourceRestoreStateFileStore.markStalled(stateFile, "RUNNER_STALLED", "restore runner heartbeat timed out");
});
}
}
}
@@ -1,13 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import java.nio.file.Path;
public record RunnerStepContext(
String taskId,
String stepCode,
int stepNo,
Path taskDir,
Path handoffFile,
Path stateFile
) {
}
@@ -1,37 +0,0 @@
package com.cisd.tms.modules.backup.runner;
public class RunnerStepExecutionResult {
private String result;
private String summary;
public static RunnerStepExecutionResult success(String summary) {
RunnerStepExecutionResult result = new RunnerStepExecutionResult();
result.setResult("SUCCESS");
result.setSummary(summary);
return result;
}
public static RunnerStepExecutionResult failed(String summary) {
RunnerStepExecutionResult result = new RunnerStepExecutionResult();
result.setResult("FAILED");
result.setSummary(summary);
return result;
}
public String getResult() {
return result;
}
public void setResult(String result) {
this.result = result;
}
public String getSummary() {
return summary;
}
public void setSummary(String summary) {
this.summary = summary;
}
}
@@ -1,8 +0,0 @@
package com.cisd.tms.modules.backup.runner;
public interface RunnerStepExecutor {
boolean supports(String stepCode);
RunnerStepExecutionResult execute(RunnerStepContext context);
}
@@ -1,82 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.cisd.tms.modules.init.config.InitExecutorProperties;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Component;
@Component
public class ServiceCommandRunnerStepExecutor implements RunnerStepExecutor {
private final ResourceBackupProperties resourceBackupProperties;
private final InitExecutorProperties initExecutorProperties;
private final RunnerCommandInvoker runnerCommandInvoker;
public ServiceCommandRunnerStepExecutor(
ResourceBackupProperties resourceBackupProperties,
InitExecutorProperties initExecutorProperties,
RunnerCommandInvoker runnerCommandInvoker
) {
this.resourceBackupProperties = resourceBackupProperties;
this.initExecutorProperties = initExecutorProperties;
this.runnerCommandInvoker = runnerCommandInvoker;
}
@Override
public boolean supports(String stepCode) {
return "STOP_SERVICES".equals(stepCode) || "START_SERVICES".equals(stepCode);
}
@Override
public RunnerStepExecutionResult execute(RunnerStepContext context) {
if (!supports(context.stepCode())) {
throw new IllegalArgumentException("unsupported runner step: " + context.stepCode());
}
List<List<String>> commands = resolveCommands(context.stepCode());
int executedCount = 0;
for (List<String> command : commands) {
int exitCode = runnerCommandInvoker.run(command, resolveLogFile(context), context.taskDir());
if (exitCode != 0) {
throw new IllegalStateException("runner step " + context.stepCode() + " failed, exitCode=" + exitCode);
}
executedCount++;
}
return RunnerStepExecutionResult.success("executed " + executedCount + " command(s)");
}
private List<List<String>> resolveCommands(String stepCode) {
return switch (stepCode) {
case "STOP_SERVICES" -> buildStopCommands();
case "START_SERVICES" -> buildStartCommands();
default -> throw new IllegalArgumentException("unsupported runner step: " + stepCode);
};
}
private List<List<String>> buildStopCommands() {
List<List<String>> commands = new ArrayList<>();
commands.add(List.of("bash", resourceBackupProperties.getTmsScriptPath(), "stop"));
appendShellCommand(commands, initExecutorProperties.getStandardAppStopCommand());
appendShellCommand(commands, initExecutorProperties.getStandardRabbitStopCommand());
return commands;
}
private List<List<String>> buildStartCommands() {
List<List<String>> commands = new ArrayList<>();
appendShellCommand(commands, initExecutorProperties.getStandardAppStartCommand());
commands.add(List.of("bash", resourceBackupProperties.getTmsScriptPath(), "start"));
return commands;
}
private void appendShellCommand(List<List<String>> commands, String shellCommand) {
if (shellCommand == null || shellCommand.isBlank()) {
return;
}
commands.add(List.of("/bin/zsh", "-lc", shellCommand.trim()));
}
private Path resolveLogFile(RunnerStepContext context) {
return context.taskDir().resolve("steps").resolve(String.format("%02d-%s.log", context.stepNo(), context.stepCode()));
}
}
@@ -1,77 +0,0 @@
package com.cisd.tms.modules.backup.runner.model;
public class ResourceRestoreHandoffSnapshot {
private String taskId;
private String precheckId;
private String backupId;
private String packageFileId;
private String packagePath;
private String stagingDir;
private String runnerStatePath;
private String restorePlanJson;
public String getTaskId() {
return taskId;
}
public void setTaskId(String taskId) {
this.taskId = taskId;
}
public String getPrecheckId() {
return precheckId;
}
public void setPrecheckId(String precheckId) {
this.precheckId = precheckId;
}
public String getBackupId() {
return backupId;
}
public void setBackupId(String backupId) {
this.backupId = backupId;
}
public String getPackageFileId() {
return packageFileId;
}
public void setPackageFileId(String packageFileId) {
this.packageFileId = packageFileId;
}
public String getPackagePath() {
return packagePath;
}
public void setPackagePath(String packagePath) {
this.packagePath = packagePath;
}
public String getStagingDir() {
return stagingDir;
}
public void setStagingDir(String stagingDir) {
this.stagingDir = stagingDir;
}
public String getRunnerStatePath() {
return runnerStatePath;
}
public void setRunnerStatePath(String runnerStatePath) {
this.runnerStatePath = runnerStatePath;
}
public String getRestorePlanJson() {
return restorePlanJson;
}
public void setRestorePlanJson(String restorePlanJson) {
this.restorePlanJson = restorePlanJson;
}
}
@@ -57,14 +57,7 @@ public class ResourcePrecheckServiceImpl implements ResourcePrecheckService {
response.setPayloadDecrypted(false);
response.setWarnings(List.of());
response.setBlockingIssues(List.of());
response.setRestorePlan(List.of(
"RESTORE_LOCK",
"STOP_SERVICES",
"RESTORE_FILES",
"RESTORE_DATABASES",
"START_SERVICES",
"CONSISTENCY_VERIFY"
));
response.setRestorePlan(List.of("JAVA_EXTRACT", "SHELL_APPLY"));
response.setRestoreScopeSummary(buildScopeSummary(inspectResult));
response.setExpiresAt(LocalDateTime.now().plusMinutes(30).toString());
resourcePrecheckStateStore.save(new ResourcePrecheckContext(request.getFileId(), response));
@@ -6,20 +6,21 @@ import com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest;
import com.cisd.tms.modules.backup.dto.response.CreateResourceRestoreResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse;
import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity;
import com.cisd.tms.modules.backup.runner.ResourceRestoreRunnerLauncher;
import com.cisd.tms.modules.backup.packagex.ResourcePayloadExtractor;
import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository;
import com.cisd.tms.modules.backup.restore.LightweightRestoreApplierLauncher;
import com.cisd.tms.modules.backup.service.ResourceRestoreService;
import com.cisd.tms.modules.backup.support.ResourcePrecheckContext;
import com.cisd.tms.modules.backup.support.ResourcePrecheckStateStore;
import com.cisd.tms.modules.backup.support.ResourceTaskIdGenerator;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.cisd.tms.modules.backup.support.file.HandoffFileStore;
import com.cisd.tms.modules.backup.support.file.ResourceRestoreStateFileStore;
import com.cisd.tms.modules.file.entity.FileRecordEntity;
import com.cisd.tms.modules.file.repository.FileRecordRepository;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.DateTimeException;
import java.time.LocalDateTime;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -32,10 +33,9 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService {
private final FileRecordRepository fileRecordRepository;
private final ResourcePrecheckStateStore resourcePrecheckStateStore;
private final ResourceTaskIdGenerator resourceTaskIdGenerator;
private final HandoffFileStore handoffFileStore;
private final ResourceRestoreStateFileStore resourceRestoreStateFileStore;
private final ResourceBackupProperties resourceBackupProperties;
private final ResourceRestoreRunnerLauncher resourceRestoreRunnerLauncher;
private final ResourcePayloadExtractor resourcePayloadExtractor;
private final LightweightRestoreApplierLauncher lightweightRestoreApplierLauncher;
@Override
@@ -47,6 +47,7 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService {
ResourcePrecheckContext context = resourcePrecheckStateStore.find(request.getPrecheckId())
.orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "resource restore precheck not found"));
ResourceRestorePrecheckResponse precheck = context.getResponse();
validatePrecheck(precheck);
if (!trim(request.getConfirmBackupId()).equals(trim(precheck.getBackupId()))) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "confirmed backup id does not match precheck result");
}
@@ -59,36 +60,31 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService {
String taskId = resourceTaskIdGenerator.nextRestoreTaskId();
LocalDateTime now = LocalDateTime.now();
Path taskDir = Path.of(resourceBackupProperties.getRestoreTaskRootDir()).resolve(taskId).normalize();
createDirectories(taskDir);
resourcePayloadExtractor.extract(Path.of(packagePath), taskDir);
ResourceRestoreTaskEntity entity = new ResourceRestoreTaskEntity();
entity.setTaskId(taskId);
entity.setPrecheckId(precheck.getPrecheckId());
entity.setBackupId(precheck.getBackupId());
// 恢复任务创建完成后,就应该已经进入“等待 runner 接管”的交接态,
// 前端和运维看到的状态不能还停留在 PRECHECKED。
entity.setStatus("HANDOFF");
// 轻量恢复模型下,Java 已完成包验签/解密/展开,后续由 shell apply 脚本接管实际覆盖/导入。
entity.setStatus("APPLYING");
entity.setSourceDeviceId("UNKNOWN");
entity.setSourceProductType(precheck.getSourceProductType());
entity.setTargetProductType(precheck.getTargetProductType());
entity.setPackageFileId(context.getFileId());
Path taskDir = Path.of(resourceBackupProperties.getRestoreTaskRootDir()).resolve(taskId).normalize();
// 恢复 runner 的工作目录和状态文件路径在任务创建时就固定下来,
// 这样后续即使 TMS 重启,控制面路径也不会漂移。
// 这里必须把真实备份包路径写进任务和 handoff,后续 runner 才能真正解包,
// 否则它只能拿到 fileId,却不知道恢复时去哪里取浏览器上传/下载回来的备份文件。
entity.setPackagePath(packagePath);
entity.setStagingDir(taskDir.resolve("staging").toString());
entity.setRunnerStatePath(taskDir.resolve("restore-state.json").toString());
entity.setStagingDir(taskDir.resolve("payload").toString());
entity.setRunnerStatePath(taskDir.resolve("status.json").toString());
entity.setStorageEncKeyFingerprint("USER_KEY_ENC:" + resourceBackupProperties.getStorageEncKeyIndex());
entity.setAuthenticityKeyFingerprint("USER_KEY_SIGN:" + resourceBackupProperties.getAuthenticitySignKeyIndex());
entity.setRestorePlanJson(joinAsJsonArray(precheck.getRestorePlan()));
entity.setRestorePlanJson("[\"JAVA_EXTRACT\",\"SHELL_APPLY\"]");
entity.setCreateBy("SYSTEM");
entity.setCreateTime(now);
entity.setUpdateTime(now);
resourceRestoreTaskRepository.save(entity);
handoffFileStore.write(taskDir, entity);
resourceRestoreStateFileStore.initialize(taskDir, entity);
resourceRestoreStateFileStore.markHandoffReady(taskDir.resolve("restore-state.json"), "RESTORE_LOCK", 1);
resourceRestoreRunnerLauncher.launch(taskDir.resolve("handoff.json"));
lightweightRestoreApplierLauncher.launch(taskDir);
CreateResourceRestoreResponse response = new CreateResourceRestoreResponse();
response.setTaskId(taskId);
@@ -97,23 +93,34 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService {
return response;
}
private static String joinAsJsonArray(List<String> items) {
if (items == null || items.isEmpty()) {
return "[]";
private static void createDirectories(Path taskDir) {
try {
Files.createDirectories(taskDir);
} catch (IOException ex) {
throw new IllegalStateException("failed to create restore task dir: " + taskDir, ex);
}
// 这里先用最轻量的 JSON 数组序列化,避免为了一个占位字段引入额外 DTO。
StringBuilder builder = new StringBuilder("[");
for (int i = 0; i < items.size(); i++) {
if (i > 0) {
builder.append(',');
}
builder.append('"').append(items.get(i)).append('"');
}
builder.append(']');
return builder.toString();
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
private static void validatePrecheck(ResourceRestorePrecheckResponse precheck) {
if (precheck == null) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "resource restore precheck not found");
}
if (!Boolean.TRUE.equals(precheck.getCompatible())) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "resource restore precheck is not compatible");
}
String expiresAt = trim(precheck.getExpiresAt());
if (!expiresAt.isEmpty()) {
try {
if (LocalDateTime.parse(expiresAt).isBefore(LocalDateTime.now())) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "resource restore precheck expired");
}
} catch (DateTimeException ex) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "resource restore precheck expiresAt is invalid");
}
}
}
}
@@ -7,17 +7,13 @@ import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepItemResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepLogResponse;
import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity;
import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository;
import com.cisd.tms.modules.backup.restore.RestoreStatusFileReader;
import com.cisd.tms.modules.backup.restore.RestoreStatusFileReader.RestoreStatusSnapshot;
import com.cisd.tms.modules.backup.service.ResourceTaskQueryService;
import com.cisd.tms.modules.backup.support.ResourceTaskStepLogService;
import com.cisd.tms.modules.backup.support.file.FileSystemResourceTaskStepLogService;
import com.cisd.tms.modules.backup.support.file.ResourceRestoreStateFileStore;
import com.cisd.tms.modules.backup.support.file.ResourceRestoreStateSnapshot;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Map;
import java.util.List;
import lombok.RequiredArgsConstructor;
@@ -28,9 +24,8 @@ import org.springframework.stereotype.Service;
public class ResourceTaskQueryServiceImpl implements ResourceTaskQueryService {
private final ResourceRestoreTaskRepository resourceRestoreTaskRepository;
private final ResourceRestoreStateFileStore resourceRestoreStateFileStore;
private final ObjectMapper objectMapper;
private final ResourceTaskStepLogService resourceTaskStepLogService;
private final RestoreStatusFileReader restoreStatusFileReader;
@Override
public ResourceRestoreTaskDetailResponse getRestoreTask(String taskId) {
@@ -48,7 +43,7 @@ public class ResourceTaskQueryServiceImpl implements ResourceTaskQueryService {
response.setTargetProductType(entity.getTargetProductType());
response.setPackageFileId(entity.getPackageFileId());
response.setPackagePathVisible(Boolean.FALSE);
response.setRunnerStatePath(entity.getRunnerStatePath());
response.setStatusFilePath(entity.getRunnerStatePath());
response.setRestorePlan(readStringList(entity.getRestorePlanJson()));
response.setConsistencyReport(readObject(entity.getConsistencyReportJson()));
response.setErrorCode(entity.getErrorCode());
@@ -57,7 +52,7 @@ public class ResourceTaskQueryServiceImpl implements ResourceTaskQueryService {
response.setCreatedAt(entity.getCreateTime() == null ? null : entity.getCreateTime().toString());
response.setStartedAt(entity.getStartTime() == null ? null : entity.getStartTime().toString());
response.setFinishedAt(entity.getFinishTime() == null ? null : entity.getFinishTime().toString());
mergeRunnerState(response, entity);
mergeLightweightStatus(response, entity);
return response;
}
@@ -66,8 +61,8 @@ public class ResourceTaskQueryServiceImpl implements ResourceTaskQueryService {
ResourceRestoreTaskEntity entity = resourceRestoreTaskRepository.findByTaskId(trim(taskId))
.orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "resource restore task not found: " + trim(taskId)));
List<String> restorePlan = readStringList(entity.getRestorePlanJson());
ResourceRestoreStateSnapshot snapshot = resourceRestoreStateFileStore.read(resolveStatePath(entity.getRunnerStatePath())).orElse(null);
Path taskDir = resolveTaskDir(entity);
RestoreStatusSnapshot snapshot = restoreStatusFileReader.read(taskDir);
List<ResourceTaskStepItemResponse> responses = new ArrayList<>();
for (int i = 0; i < restorePlan.size(); i++) {
String stepCode = restorePlan.get(i);
@@ -75,7 +70,7 @@ public class ResourceTaskQueryServiceImpl implements ResourceTaskQueryService {
ResourceTaskStepItemResponse item = new ResourceTaskStepItemResponse();
item.setStepNo(stepNo);
item.setStepCode(stepCode);
applyStepState(item, taskDir, snapshot, stepCode, stepNo);
applyLightweightStepState(item, snapshot, stepCode);
responses.add(item);
}
return responses;
@@ -87,38 +82,36 @@ public class ResourceTaskQueryServiceImpl implements ResourceTaskQueryService {
.orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "resource restore task not found: " + trim(taskId)));
List<String> restorePlan = readStringList(entity.getRestorePlanJson());
String stepCode = stepNo > 0 && stepNo <= restorePlan.size() ? restorePlan.get(stepNo - 1) : "";
return resourceTaskStepLogService.read(resolveTaskDir(entity), trim(taskId), stepNo, stepCode);
RestoreStatusSnapshot snapshot = restoreStatusFileReader.read(resolveTaskDir(entity));
ResourceTaskStepLogResponse response = new ResourceTaskStepLogResponse();
response.setTaskId(trim(taskId));
response.setStepNo(stepNo);
response.setStepCode(stepCode);
response.setContent(snapshot.log());
response.setTruncated(false);
return response;
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
private void mergeRunnerState(ResourceRestoreTaskDetailResponse response, ResourceRestoreTaskEntity entity) {
// 恢复任务详情以数据库记录为基线,再用 runner 状态文件覆盖实时字段。
// 这样即使 runner 尚未接管,前端也能看到稳定的任务基本信息。
resourceRestoreStateFileStore.read(resolveStatePath(entity.getRunnerStatePath()))
.ifPresent(snapshot -> applySnapshot(response, snapshot));
private void mergeLightweightStatus(ResourceRestoreTaskDetailResponse response, ResourceRestoreTaskEntity entity) {
RestoreStatusSnapshot snapshot = restoreStatusFileReader.read(resolveTaskDir(entity));
if (trim(snapshot.status()).isEmpty()) {
return;
}
response.setStatus(defaultIfBlank(snapshot.status(), response.getStatus()));
response.setControlPhase(defaultIfBlank(snapshot.phase(), response.getControlPhase()));
response.setStateSource("SHELL_STATUS");
response.setCurrentStepCode(defaultIfBlank(snapshot.phase(), response.getCurrentStepCode()));
response.setCurrentStepNo(currentStepNo(snapshot.phase(), response.getRestorePlan()));
response.setLastHeartbeatAt(defaultIfBlank(snapshot.updatedAt(), response.getLastHeartbeatAt()));
response.setErrorMessage(defaultIfBlank(snapshot.message(), response.getErrorMessage()));
}
private void applySnapshot(ResourceRestoreTaskDetailResponse response, ResourceRestoreStateSnapshot snapshot) {
response.setStatus(defaultIfBlank(snapshot.getStatus(), response.getStatus()));
response.setControlPhase(defaultIfBlank(snapshot.getControlPhase(), response.getControlPhase()));
response.setStateSource(defaultIfBlank(snapshot.getStateSource(), response.getStateSource()));
response.setCurrentStepCode(defaultIfBlank(snapshot.getCurrentStepCode(), response.getCurrentStepCode()));
response.setCurrentStepNo(snapshot.getCurrentStepNo() == null ? response.getCurrentStepNo() : snapshot.getCurrentStepNo());
response.setLastHeartbeatAt(defaultIfBlank(snapshot.getLastHeartbeatAt(), response.getLastHeartbeatAt()));
response.setOrphaned(snapshot.getOrphaned() == null ? response.getOrphaned() : snapshot.getOrphaned());
response.setCleanupWarnings(defaultIfNull(snapshot.getCleanupWarnings(), response.getCleanupWarnings()));
response.setWarnings(defaultIfNull(snapshot.getWarnings(), response.getWarnings()));
response.setBlockingIssues(defaultIfNull(snapshot.getBlockingIssues(), response.getBlockingIssues()));
response.setConsistencyReport(snapshot.getConsistencyReport() == null ? response.getConsistencyReport() : snapshot.getConsistencyReport());
response.setErrorCode(defaultIfBlank(snapshot.getErrorCode(), response.getErrorCode()));
response.setErrorMessage(defaultIfBlank(snapshot.getErrorMessage(), response.getErrorMessage()));
}
private Path resolveStatePath(String runnerStatePath) {
return trim(runnerStatePath).isEmpty() ? null : Path.of(runnerStatePath).normalize();
private Path resolveStatePath(String statusFilePath) {
return trim(statusFilePath).isEmpty() ? null : Path.of(statusFilePath).normalize();
}
private Path resolveTaskDir(ResourceRestoreTaskEntity entity) {
@@ -126,58 +119,29 @@ public class ResourceTaskQueryServiceImpl implements ResourceTaskQueryService {
return statePath == null ? null : statePath.getParent();
}
private void applyStepState(
ResourceTaskStepItemResponse item,
Path taskDir,
ResourceRestoreStateSnapshot snapshot,
String stepCode,
int stepNo
) {
Map<String, Object> stepOutput = readStepOutput(taskDir, stepNo, stepCode);
item.setLogAvailable(taskDir != null && Files.isRegularFile(taskDir.resolve("steps").resolve(String.format("%02d-%s.log", stepNo, stepCode))));
if (stepOutput != null) {
item.setStatus(stringValue(stepOutput.get("result"), "UNKNOWN"));
item.setMessage(stringValue(stepOutput.get("summary"), ""));
private void applyLightweightStepState(ResourceTaskStepItemResponse item, RestoreStatusSnapshot snapshot, String stepCode) {
item.setLogAvailable(!trim(snapshot.log()).isEmpty());
if (trim(snapshot.status()).isEmpty()) {
item.setStatus("PENDING");
return;
}
if (snapshot != null && stepNo == safeInt(snapshot.getCurrentStepNo())) {
if ("RUNNER_OWNED".equals(snapshot.getControlPhase())) {
item.setStatus("RUNNING");
} else if ("RECONCILE_PENDING".equals(snapshot.getControlPhase()) && "FAILED".equals(snapshot.getStatus())) {
item.setStatus("FAILED");
item.setMessage(defaultIfBlank(snapshot.getErrorMessage(), ""));
} else {
item.setStatus("PENDING");
}
if ("JAVA_EXTRACT".equals(stepCode)) {
item.setStatus("SUCCESS");
return;
}
if ("SHELL_APPLY".equals(stepCode)) {
item.setStatus(snapshot.status());
item.setMessage(snapshot.message());
return;
}
if (stepCode.equals(snapshot.phase())) {
item.setStatus(snapshot.status());
item.setMessage(snapshot.message());
return;
}
item.setStatus("PENDING");
}
private Map<String, Object> readStepOutput(Path taskDir, int stepNo, String stepCode) {
if (taskDir == null) {
return null;
}
Path outputFile = taskDir.resolve("step-output").resolve(String.format("%02d-%s.json", stepNo, stepCode));
if (!Files.isRegularFile(outputFile)) {
return null;
}
try {
return objectMapper.readValue(outputFile.toFile(), new TypeReference<Map<String, Object>>() {
});
} catch (Exception ex) {
throw new IllegalStateException("failed to parse resource task step output", ex);
}
}
private static int safeInt(Integer value) {
return value == null ? -1 : value;
}
private static String stringValue(Object value, String defaultValue) {
return value == null ? defaultValue : String.valueOf(value);
}
private List<String> readStringList(String json) {
if (trim(json).isEmpty()) {
return List.of();
@@ -205,7 +169,15 @@ public class ResourceTaskQueryServiceImpl implements ResourceTaskQueryService {
return trim(candidate).isEmpty() ? fallback : candidate;
}
private static <T> T defaultIfNull(T candidate, T fallback) {
return candidate == null ? fallback : candidate;
private static Integer currentStepNo(String phase, List<String> restorePlan) {
if (restorePlan == null || restorePlan.isEmpty()) {
return null;
}
for (int i = 0; i < restorePlan.size(); i++) {
if (restorePlan.get(i).equals(phase)) {
return i + 1;
}
}
return restorePlan.contains("SHELL_APPLY") ? restorePlan.indexOf("SHELL_APPLY") + 1 : null;
}
}
@@ -1,52 +0,0 @@
package com.cisd.tms.modules.backup.support.file;
import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity;
import com.cisd.tms.modules.backup.runner.model.ResourceRestoreHandoffSnapshot;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import org.springframework.stereotype.Component;
@Component
public class HandoffFileStore {
private final ObjectMapper objectMapper;
public HandoffFileStore(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public void write(Path taskDir, ResourceRestoreTaskEntity entity) {
try {
Files.createDirectories(taskDir);
Path file = taskDir.resolve("handoff.json");
Map<String, Object> json = new LinkedHashMap<>();
json.put("taskId", entity.getTaskId());
json.put("precheckId", entity.getPrecheckId());
json.put("backupId", entity.getBackupId());
json.put("packageFileId", entity.getPackageFileId());
json.put("packagePath", entity.getPackagePath());
json.put("stagingDir", entity.getStagingDir());
json.put("runnerStatePath", entity.getRunnerStatePath());
json.put("restorePlanJson", entity.getRestorePlanJson());
objectMapper.writeValue(file.toFile(), json);
} catch (IOException ex) {
throw new IllegalStateException("failed to write restore handoff file", ex);
}
}
public Optional<ResourceRestoreHandoffSnapshot> read(Path handoffFile) {
if (handoffFile == null || !Files.isRegularFile(handoffFile)) {
return Optional.empty();
}
try {
return Optional.ofNullable(objectMapper.readValue(handoffFile.toFile(), ResourceRestoreHandoffSnapshot.class));
} catch (IOException ex) {
throw new IllegalStateException("failed to read restore handoff file", ex);
}
}
}
@@ -1,190 +0,0 @@
package com.cisd.tms.modules.backup.support.file;
import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import org.springframework.stereotype.Component;
@Component
public class ResourceRestoreStateFileStore {
private final ObjectMapper objectMapper;
public ResourceRestoreStateFileStore(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public void initialize(Path taskDir, ResourceRestoreTaskEntity entity) {
try {
Files.createDirectories(taskDir);
Map<String, Object> json = new LinkedHashMap<>();
json.put("taskId", entity.getTaskId());
json.put("status", entity.getStatus());
json.put("controlPhase", "PRECHECKED");
json.put("stateSource", "DB");
json.put("runnerInstanceId", "");
json.put("runnerPid", 0);
json.put("ownershipToken", UUID.randomUUID().toString());
json.put("orphaned", false);
objectMapper.writeValue(taskDir.resolve("restore-state.json").toFile(), json);
} catch (IOException ex) {
throw new IllegalStateException("failed to initialize restore state file", ex);
}
}
public java.util.Optional<ResourceRestoreStateSnapshot> read(Path stateFile) {
if (stateFile == null || !Files.isRegularFile(stateFile)) {
return java.util.Optional.empty();
}
try {
return java.util.Optional.ofNullable(objectMapper.readValue(stateFile.toFile(), ResourceRestoreStateSnapshot.class));
} catch (IOException ex) {
throw new IllegalStateException("failed to read restore state file", ex);
}
}
public void markHandoffReady(Path stateFile, String currentStepCode, Integer currentStepNo) {
try {
Map<String, Object> json = readJson(stateFile);
json.put("status", "HANDOFF");
json.put("controlPhase", "HANDOFF_READY");
json.put("stateSource", "DB");
json.put("currentStepCode", currentStepCode);
json.put("currentStepNo", currentStepNo);
json.put("orphaned", false);
objectMapper.writeValue(stateFile.toFile(), json);
} catch (IOException ex) {
throw new IllegalStateException("failed to mark restore state as handoff ready", ex);
}
}
public void claimOwnership(Path stateFile, String runnerInstanceId, long runnerPid, String currentStepCode, Integer currentStepNo) {
try {
Map<String, Object> json = readJson(stateFile);
String controlPhase = String.valueOf(json.getOrDefault("controlPhase", ""));
if (!"HANDOFF_READY".equals(controlPhase)) {
throw new IllegalStateException("restore state is not handoff ready: " + controlPhase);
}
json.put("status", "RUNNING");
json.put("controlPhase", "RUNNER_OWNED");
json.put("stateSource", "RUNNER_FILE");
json.put("runnerInstanceId", runnerInstanceId);
json.put("runnerPid", runnerPid);
json.put("currentStepCode", currentStepCode);
json.put("currentStepNo", currentStepNo);
json.put("lastHeartbeatAt", java.time.LocalDateTime.now().toString());
json.put("orphaned", false);
objectMapper.writeValue(stateFile.toFile(), json);
} catch (IOException ex) {
throw new IllegalStateException("failed to claim restore runner ownership", ex);
}
}
public void markStepRunning(Path stateFile, String currentStepCode, Integer currentStepNo, String runnerInstanceId) {
updateOwnedState(stateFile, runnerInstanceId, json -> {
json.put("status", "RUNNING");
json.put("controlPhase", "RUNNER_OWNED");
json.put("stateSource", "RUNNER_FILE");
json.put("currentStepCode", currentStepCode);
json.put("currentStepNo", currentStepNo);
json.put("lastHeartbeatAt", java.time.LocalDateTime.now().toString());
}, "failed to mark restore step running");
}
public void touchHeartbeat(Path stateFile, String runnerInstanceId) {
updateOwnedState(stateFile, runnerInstanceId, json ->
json.put("lastHeartbeatAt", java.time.LocalDateTime.now().toString()), "failed to touch restore heartbeat");
}
public void markReconcilePending(
Path stateFile,
String status,
String errorCode,
String errorMessage,
String currentStepCode,
Integer currentStepNo,
String runnerInstanceId
) {
updateOwnedState(stateFile, runnerInstanceId, json -> {
json.put("status", status);
json.put("controlPhase", "RECONCILE_PENDING");
json.put("stateSource", "RUNNER_FILE");
json.put("currentStepCode", currentStepCode);
json.put("currentStepNo", currentStepNo);
json.put("errorCode", errorCode == null ? "" : errorCode);
json.put("errorMessage", errorMessage == null ? "" : errorMessage);
json.put("lastHeartbeatAt", java.time.LocalDateTime.now().toString());
}, "failed to mark restore reconcile pending");
}
private void updateOwnedState(
Path stateFile,
String runnerInstanceId,
JsonMutator mutator,
String errorMessage
) {
try {
Map<String, Object> json = readJson(stateFile);
String currentRunnerInstanceId = String.valueOf(json.getOrDefault("runnerInstanceId", ""));
if (!runnerInstanceId.equals(currentRunnerInstanceId)) {
throw new IllegalStateException("restore runner ownership changed: " + currentRunnerInstanceId);
}
mutator.accept(json);
objectMapper.writeValue(stateFile.toFile(), json);
} catch (IOException ex) {
throw new IllegalStateException(errorMessage, ex);
}
}
public void markDone(Path stateFile) {
try {
Map<String, Object> json = readJson(stateFile);
json.put("controlPhase", "DONE");
json.put("stateSource", "DB");
json.put("lastHeartbeatAt", LocalDateTime.now().toString());
objectMapper.writeValue(stateFile.toFile(), json);
} catch (IOException ex) {
throw new IllegalStateException("failed to mark restore state done", ex);
}
}
public void markStalled(Path stateFile, String errorCode, String errorMessage) {
try {
Map<String, Object> json = readJson(stateFile);
if (!"RUNNER_OWNED".equals(String.valueOf(json.getOrDefault("controlPhase", "")))) {
return;
}
json.put("status", "FAILED");
json.put("controlPhase", "RECONCILE_PENDING");
json.put("stateSource", "RUNNER_FILE");
json.put("orphaned", true);
json.put("errorCode", errorCode);
json.put("errorMessage", errorMessage);
json.put("lastHeartbeatAt", LocalDateTime.now().toString());
objectMapper.writeValue(stateFile.toFile(), json);
} catch (IOException ex) {
throw new IllegalStateException("failed to mark stalled restore state", ex);
}
}
@FunctionalInterface
private interface JsonMutator {
void accept(Map<String, Object> json);
}
private Map<String, Object> readJson(Path stateFile) throws IOException {
if (stateFile == null || !Files.isRegularFile(stateFile)) {
throw new IllegalStateException("restore state file not found: " + stateFile);
}
return objectMapper.readValue(
stateFile.toFile(),
objectMapper.getTypeFactory().constructMapType(LinkedHashMap.class, String.class, Object.class)
);
}
}
@@ -1,160 +0,0 @@
package com.cisd.tms.modules.backup.support.file;
import java.util.List;
public class ResourceRestoreStateSnapshot {
private String taskId;
private String status;
private String controlPhase;
private String stateSource;
private String runnerInstanceId;
private Long runnerPid;
private String ownershipToken;
private String currentStepCode;
private Integer currentStepNo;
private String lastHeartbeatAt;
private Boolean orphaned;
private List<String> cleanupWarnings;
private List<String> warnings;
private List<String> blockingIssues;
private Object consistencyReport;
private String errorCode;
private String errorMessage;
public String getTaskId() {
return taskId;
}
public void setTaskId(String taskId) {
this.taskId = taskId;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getControlPhase() {
return controlPhase;
}
public void setControlPhase(String controlPhase) {
this.controlPhase = controlPhase;
}
public String getStateSource() {
return stateSource;
}
public void setStateSource(String stateSource) {
this.stateSource = stateSource;
}
public String getRunnerInstanceId() {
return runnerInstanceId;
}
public void setRunnerInstanceId(String runnerInstanceId) {
this.runnerInstanceId = runnerInstanceId;
}
public Long getRunnerPid() {
return runnerPid;
}
public void setRunnerPid(Long runnerPid) {
this.runnerPid = runnerPid;
}
public String getOwnershipToken() {
return ownershipToken;
}
public void setOwnershipToken(String ownershipToken) {
this.ownershipToken = ownershipToken;
}
public String getCurrentStepCode() {
return currentStepCode;
}
public void setCurrentStepCode(String currentStepCode) {
this.currentStepCode = currentStepCode;
}
public Integer getCurrentStepNo() {
return currentStepNo;
}
public void setCurrentStepNo(Integer currentStepNo) {
this.currentStepNo = currentStepNo;
}
public String getLastHeartbeatAt() {
return lastHeartbeatAt;
}
public void setLastHeartbeatAt(String lastHeartbeatAt) {
this.lastHeartbeatAt = lastHeartbeatAt;
}
public Boolean getOrphaned() {
return orphaned;
}
public void setOrphaned(Boolean orphaned) {
this.orphaned = orphaned;
}
public List<String> getCleanupWarnings() {
return cleanupWarnings;
}
public void setCleanupWarnings(List<String> cleanupWarnings) {
this.cleanupWarnings = cleanupWarnings;
}
public List<String> getWarnings() {
return warnings;
}
public void setWarnings(List<String> warnings) {
this.warnings = warnings;
}
public List<String> getBlockingIssues() {
return blockingIssues;
}
public void setBlockingIssues(List<String> blockingIssues) {
this.blockingIssues = blockingIssues;
}
public Object getConsistencyReport() {
return consistencyReport;
}
public void setConsistencyReport(Object consistencyReport) {
this.consistencyReport = consistencyReport;
}
public String getErrorCode() {
return errorCode;
}
public void setErrorCode(String errorCode) {
this.errorCode = errorCode;
}
public String getErrorMessage() {
return errorMessage;
}
public void setErrorMessage(String errorMessage) {
this.errorMessage = errorMessage;
}
}
@@ -8,9 +8,10 @@ import java.util.List;
@Data
public class EntityCreateRequest {
@NotBlank
private String entityName;
private String orgCode;
@NotBlank
private String orgName;
@NotBlank
private String algoType;
private List<String> allowIps;
}
@@ -8,8 +8,9 @@ import lombok.Data;
public class EntityGenP10Request {
@NotNull
private Long id;
@NotNull
private String certMode;
@Valid
@NotNull
private Subject subject;
}
@@ -8,12 +8,14 @@ import java.util.List;
@Data
public class EntityItemResponse {
private Long id;
private String entityName;
private String orgCode;
private String orgName;
private String algoType;
private Integer keyLen;
private Integer keyIdx;
private String subject;
private List<String> allowIps;
private String status;
private String statusName;
private LocalDateTime createTime;
}
@@ -6,7 +6,7 @@ import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class EntityListRequest extends CommonPageRequest {
private String keyword;
private String orgCode;
private String algoType;
private String status;
}
@@ -9,7 +9,7 @@ import java.util.List;
public class EntityUpdateRequest {
@NotNull
private Long id;
private String entityName;
private String orgCode;
private String orgName;
private List<String> allowIps;
}
@@ -12,7 +12,8 @@ import lombok.experimental.Accessors;
@TableName("t_key_entity")
public class KeyEntity extends BaseEntity {
private String entityName;
private String orgCode;
private String orgName;
private String algoType;
private Integer keyLen;
private Integer keyIdx;
@@ -20,8 +21,9 @@ public class KeyEntity extends BaseEntity {
private String publicKey;
private String p10Data;
private String subjectDn;
private String certMode;
private String status;
private String allowIps;
private String signData;
private String createBy;
}
@@ -0,0 +1,34 @@
package com.cisd.tms.modules.cert.enums;
import com.cisd.tms.common.enums.ErrorCode;
import com.cisd.tms.common.exception.BizException;
import java.util.Arrays;
public enum EntityCertMode {
SINGLE("SINGLE", "单证"),
DOUBLE("DOUBLE", "双证");
private final String code;
private final String displayName;
EntityCertMode(String code, String displayName) {
this.code = code;
this.displayName = displayName;
}
public String getCode() {
return code;
}
public String getDisplayName() {
return displayName;
}
public static EntityCertMode requireValid(String code) {
return Arrays.stream(values())
.filter(value -> value.code.equalsIgnoreCase(code))
.findFirst()
.orElseThrow(() -> new BizException(ErrorCode.BAD_REQUEST.getCode(), "invalid entity cert mode"));
}
}
@@ -0,0 +1,35 @@
package com.cisd.tms.modules.cert.enums;
import com.cisd.tms.common.enums.ErrorCode;
import com.cisd.tms.common.exception.BizException;
import java.util.Arrays;
public enum EntityStatus {
CREATED("CREATED", "已创建"),
P10_GENERATED("P10_GENERATED", "已生成P10请求"),
CERT_IMPORTED("CERT_IMPORTED", "已导入证书");
private final String code;
private final String displayName;
EntityStatus(String code, String displayName) {
this.code = code;
this.displayName = displayName;
}
public String getCode() {
return code;
}
public String getDisplayName() {
return displayName;
}
public static EntityStatus requireValid(String code) {
return Arrays.stream(values())
.filter(value -> value.code.equalsIgnoreCase(code))
.findFirst()
.orElseThrow(() -> new BizException(ErrorCode.BAD_REQUEST.getCode(), "invalid entity status"));
}
}
@@ -13,5 +13,5 @@ public interface KeyEntityRepository {
Optional<KeyEntity> findByKeyIdx(Integer keyIdx);
List<Integer> findUsedKeyIdx();
Page<KeyEntity> page(EntityListRequest request);
void updateStatus(Long id, String status);
}
@@ -1,6 +1,7 @@
package com.cisd.tms.modules.cert.repository.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.cisd.tms.modules.cert.dto.EntityListRequest;
import com.cisd.tms.modules.cert.entity.KeyEntity;
@@ -62,12 +63,14 @@ public class KeyEntityRepositoryImpl implements KeyEntityRepository {
@Override
public Page<KeyEntity> page(EntityListRequest request) {
LambdaQueryWrapper<KeyEntity> wrapper = new LambdaQueryWrapper<>();
if (StringUtils.hasText(request.getKeyword())) {
wrapper.and(w -> w.like(KeyEntity::getEntityName, request.getKeyword())
.or().like(KeyEntity::getSubjectDn, request.getKeyword()));
if (StringUtils.hasText(request.getOrgCode())) {
wrapper.like(KeyEntity::getOrgCode, request.getOrgCode());
}
if (StringUtils.hasText(request.getAlgoType())) {
wrapper.eq(KeyEntity::getAlgoType, request.getAlgoType());
wrapper.eq(KeyEntity::getAlgoType, request.getAlgoType().toUpperCase());
}
if (StringUtils.hasText(request.getStatus())) {
wrapper.eq(KeyEntity::getStatus, request.getStatus().toUpperCase());
}
wrapper.orderByDesc(KeyEntity::getCreateTime);
long total = mapper.selectCount(wrapper);
@@ -78,5 +81,11 @@ public class KeyEntityRepositoryImpl implements KeyEntityRepository {
page.setRecords(records);
return page;
}
}
@Override
public void updateStatus(Long id, String status) {
mapper.update(null, new LambdaUpdateWrapper<KeyEntity>()
.eq(KeyEntity::getId, id)
.set(KeyEntity::getStatus, status));
}
}
@@ -92,6 +92,9 @@ public class CertificateService {
entity.setCreateTime(LocalDateTime.now());
entity.setUpdateTime(LocalDateTime.now());
certificateRepository.save(entity);
if (finalEntityId != null) {
entityService.markCertificateImported(finalEntityId);
}
return new ImportCertificateResponse(entity.getId(), entity.getSubjectDn(), entity.getValidTo());
} catch (BizException ex) {
throw ex;
@@ -16,6 +16,8 @@ import com.cisd.tms.modules.cert.dto.EntityListRequest;
import com.cisd.tms.modules.cert.dto.EntityUpdateRequest;
import com.cisd.tms.modules.cert.dto.Subject;
import com.cisd.tms.modules.cert.entity.KeyEntity;
import com.cisd.tms.modules.cert.enums.EntityCertMode;
import com.cisd.tms.modules.cert.enums.EntityStatus;
import com.cisd.tms.modules.cert.repository.CertificateRepository;
import com.cisd.tms.modules.cert.repository.KeyEntityRepository;
import com.cisd.tms.modules.cert.support.Sm2KeySupport;
@@ -39,11 +41,13 @@ import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Locale;
@Service
public class EntityService {
private static final String SM2 = "SM2";
private static final String RSA = "RSA";
private static final byte[] DEFAULT_SM2_USER_ID = "1234567812345678".getBytes(StandardCharsets.UTF_8);
private final KeyEntityRepository keyEntityRepository;
@@ -72,26 +76,33 @@ public class EntityService {
@Transactional
public EntityCreateResponse create(EntityCreateRequest request) {
if (!SM2.equalsIgnoreCase(request.getAlgoType())) {
throw new BizException(ErrorCode.BAD_REQUEST.getCode(), "only SM2 is supported");
}
String algoType = normalizeAlgoType(request.getAlgoType());
int keyIdx = resolveNextKeyIdx();
int keyLen = SM2.equals(algoType) ? 256 : 2048;
GenerateKeyPairRequest generateRequest = new GenerateKeyPairRequest();
generateRequest.setKeyBits(256);
generateRequest.setKeyBits(keyLen);
generateRequest.setKeyIndex(keyIdx);
generateRequest.setKeyType(PcieUserKeyType.SIGN.getCode());
pcieCryptoService.generateKeyPairEcc(generateRequest);
if (SM2.equals(algoType)) {
pcieCryptoService.generateKeyPairEcc(generateRequest);
} else {
pcieCryptoService.generateKeyPairRsa(generateRequest);
}
try {
BackupDataResult backup = pcieCryptoService.backupUserKey(keyIdx, PcieUserKeyType.SIGN.getCode(), 4096);
BackupDataResult publicKey = pcieCryptoService.exportSignPublicKeyEcc(keyIdx);
BackupDataResult publicKey = SM2.equals(algoType)
? pcieCryptoService.exportSignPublicKeyEcc(keyIdx)
: pcieCryptoService.exportSignPublicKeyRsa(keyIdx);
KeyEntity entity = new KeyEntity()
.setEntityName(request.getEntityName())
.setAlgoType(SM2)
.setKeyLen(256)
.setOrgCode(request.getOrgCode())
.setOrgName(request.getOrgName())
.setAlgoType(algoType)
.setKeyLen(keyLen)
.setKeyIdx(keyIdx)
.setBackupData(Base64.getEncoder().encodeToString(backup.getData()))
.setPublicKey(Base64.getEncoder().encodeToString(publicKey.getData()))
.setStatus(EntityStatus.CREATED.getCode())
.setAllowIps(writeAllowIps(request.getAllowIps()))
.setCreateBy("system");
entity.setCreateTime(LocalDateTime.now());
@@ -110,8 +121,11 @@ public class EntityService {
@Transactional
public void update(EntityUpdateRequest request) {
KeyEntity entity = getById(request.getId());
if (StringUtils.hasText(request.getEntityName())) {
entity.setEntityName(request.getEntityName());
if (StringUtils.hasText(request.getOrgCode())) {
entity.setOrgCode(request.getOrgCode());
}
if (StringUtils.hasText(request.getOrgName())) {
entity.setOrgName(request.getOrgName());
}
if (request.getAllowIps() != null) {
entity.setAllowIps(writeAllowIps(request.getAllowIps()));
@@ -132,6 +146,10 @@ public class EntityService {
@Transactional
public String generateP10(EntityGenP10Request request) {
KeyEntity entity = getById(request.getId());
if (!SM2.equalsIgnoreCase(entity.getAlgoType())) {
throw new BizException(ErrorCode.BAD_REQUEST.getCode(), "only SM2 P10 is supported");
}
EntityCertMode certMode = EntityCertMode.requireValid(request.getCertMode());
Subject subject = request.getSubject();
X500Name subjectName = subject.toX500Name();
String subjectDn = subjectName.toString();
@@ -142,6 +160,8 @@ public class EntityService {
String pem = encodePkcs10Pem(csr);
entity.setSubjectDn(subjectDn);
entity.setP10Data(pem);
entity.setCertMode(certMode.getCode());
entity.setStatus(EntityStatus.P10_GENERATED.getCode());
keyEntityRepository.save(entity);
return pem;
}
@@ -156,19 +176,40 @@ public class EntityService {
.orElseThrow(() -> new BizException(ErrorCode.BAD_REQUEST.getCode(), "entity not found"));
}
@Transactional
public void markCertificateImported(Long id) {
keyEntityRepository.updateStatus(id, EntityStatus.CERT_IMPORTED.getCode());
}
private EntityItemResponse toItem(KeyEntity entity) {
EntityItemResponse response = new EntityItemResponse();
response.setId(entity.getId());
response.setEntityName(entity.getEntityName());
response.setOrgCode(entity.getOrgCode());
response.setOrgName(entity.getOrgName());
response.setAlgoType(entity.getAlgoType());
response.setKeyLen(entity.getKeyLen());
response.setKeyIdx(entity.getKeyIdx());
response.setSubject(entity.getSubjectDn());
response.setAllowIps(readAllowIps(entity.getAllowIps()));
response.setStatus(entity.getStatus());
if (StringUtils.hasText(entity.getStatus())) {
response.setStatusName(EntityStatus.requireValid(entity.getStatus()).getDisplayName());
}
response.setCreateTime(entity.getCreateTime());
return response;
}
private String normalizeAlgoType(String algoType) {
if (!StringUtils.hasText(algoType)) {
throw new BizException(ErrorCode.BAD_REQUEST.getCode(), "algoType is required");
}
String normalized = algoType.toUpperCase(Locale.ROOT);
if (!SM2.equals(normalized) && !RSA.equals(normalized)) {
throw new BizException(ErrorCode.BAD_REQUEST.getCode(), "algoType must be SM2 or RSA");
}
return normalized;
}
private int resolveNextKeyIdx() {
List<Integer> used = keyEntityRepository.findUsedKeyIdx();
for (int idx = 2; idx <= 8; idx++) {
+13 -9
View File
@@ -184,16 +184,24 @@ tms:
backup:
# 资源备份包输出目录;第一版先在本机生成可见的 .tmsbak 文件。
output-dir: ${TMS_BACKUP_OUTPUT_DIR:/home/tms/tmp/resource-backup-packages}
# 恢复 runner 启停 TMS 时使用的脚本路径。
# 轻量恢复 apply 脚本启停 TMS 时使用的脚本路径。
tms-script-path: ${TMS_BACKUP_TMS_SCRIPT_PATH:/home/tms/scripts/tms.sh}
# 外部恢复 runner 启动脚本路径;创建恢复任务后由主进程调用该脚本拉起独立 runner。
runner-launch-script-path: ${TMS_BACKUP_RUNNER_LAUNCH_SCRIPT_PATH:/home/tms/bin/resource-restore/start-runner.sh}
# 轻量恢复 apply 脚本路径;Java 完成验签/解密/展开后调用该脚本执行覆盖、SQL、MQ replay。
restore-apply-script-path: ${TMS_BACKUP_RESTORE_APPLY_SCRIPT_PATH:/home/tms/bin/resource-restore/apply-resource-backup.sh}
# 文件恢复白名单根路径;manifest 中 FILE 恢复路径必须落在这些目录下。
allowed-restore-roots:
- ${TMS_BACKUP_ALLOWED_RESTORE_ROOT_TMS_CONFIG:/home/tms/config}
- ${TMS_BACKUP_ALLOWED_RESTORE_ROOT_CMEP4I:/home/cmep4i}
- ${TMS_BACKUP_ALLOWED_RESTORE_ROOT_CMEP4D:/home/cmep4d}
- ${TMS_BACKUP_ALLOWED_RESTORE_ROOT_WORK:/home/tms/tmp/resource-restore}
# 导出 TMS 库时使用的 mysqldump 命令;现场可按实际安装路径覆盖。
mysqldump-path: ${TMS_BACKUP_MYSQLDUMP_PATH:mysqldump}
# 恢复 TMS 库时使用的 mysql 客户端命令。
mysql-path: ${TMS_BACKUP_MYSQL_PATH:mysql}
# 数据库恢复脚本路径;runner 在 RESTORE_DATABASES 阶段调用。
# 数据库恢复脚本路径;apply 脚本在 RESTORE_DATABASES 阶段调用。
restore-db-script-path: ${TMS_BACKUP_RESTORE_DB_SCRIPT_PATH:/home/tms/bin/resource-restore/restore-db.sh}
# MQ replay 脚本路径;apply 脚本在 MQ_REPLAY 阶段调用。
mq-replay-script-path: ${TMS_BACKUP_MQ_REPLAY_SCRIPT_PATH:/home/tms/bin/resource-restore/replay-mq.sh}
# 备份 TMS 库名;为空时默认从 spring.datasource.url 中解析。
tms-database-name: ${TMS_BACKUP_TMS_DATABASE_NAME:}
# 备份/恢复标准收发器业务库名;第一版默认使用 CMEP。
@@ -202,10 +210,6 @@ tms:
health-check-url: ${TMS_BACKUP_HEALTH_CHECK_URL:http://127.0.0.1:8080/actuator/health}
# 健康检查请求超时(秒)。
health-check-timeout-seconds: ${TMS_BACKUP_HEALTH_CHECK_TIMEOUT_SECONDS:5}
# 是否在创建恢复任务后自动拉起本机 runner;默认关闭,避免开发环境误触停机恢复链。
runner-auto-launch-enabled: ${TMS_BACKUP_RUNNER_AUTO_LAUNCH_ENABLED:false}
# 恢复 runner 心跳失联超时(秒)。
runner-heartbeat-timeout-seconds: ${TMS_BACKUP_RUNNER_HEARTBEAT_TIMEOUT_SECONDS:300}
mk:
init-identify:
# SDFE_InitIdentify 旧 PIN(Base64 编码);主密钥初始化接口会读取该值。
@@ -217,7 +221,7 @@ tms:
internal-token: ${TMS_INTERNAL_TOKEN:change-me-internal-token}
internal-auth:
# 是否启用 /api/** 的内部登录鉴权;开发联调时可临时关闭。
enabled: ${TMS_SECURITY_INTERNAL_AUTH_ENABLED:false}
enabled: ${TMS_SECURITY_INTERNAL_AUTH_ENABLED:true}
# 关闭内部鉴权后,注入请求上下文的调试角色。
debug-role-code: ${TMS_SECURITY_INTERNAL_AUTH_DEBUG_ROLE_CODE:SUPER_ADMIN}
# 关闭内部鉴权后,注入请求上下文的调试认证等级。