资源备份恢复

This commit is contained in:
waner
2026-04-20 10:15:25 +08:00
parent 350fa4d89d
commit 2efdc047e3
26 changed files with 1712 additions and 329 deletions
@@ -9,6 +9,14 @@ public class ResourceBackupProperties {
private String precheckStoreDir = "/home/tms/tmp/resource-restore-precheck";
private String restoreTaskRootDir = "/home/tms/tmp/resource-restore";
private String tmsScriptPath = "/home/tms/scripts/tms.sh";
private String runnerLaunchScriptPath = "/home/tms/bin/resource-restore/start-runner.sh";
private String mysqldumpPath = "mysqldump";
private String mysqlPath = "mysql";
private String restoreDbScriptPath = "/home/tms/bin/resource-restore/restore-db.sh";
private String tmsDatabaseName = "";
private String cmepDatabaseName = "CMEP";
private String healthCheckUrl = "http://127.0.0.1:8080/actuator/health";
private int healthCheckTimeoutSeconds = 5;
private boolean runnerAutoLaunchEnabled = false;
private int runnerHeartbeatTimeoutSeconds = 300;
private int storageEncKeyIndex = 1;
@@ -46,6 +54,70 @@ public class ResourceBackupProperties {
this.tmsScriptPath = tmsScriptPath;
}
public String getRunnerLaunchScriptPath() {
return runnerLaunchScriptPath;
}
public void setRunnerLaunchScriptPath(String runnerLaunchScriptPath) {
this.runnerLaunchScriptPath = runnerLaunchScriptPath;
}
public String getMysqldumpPath() {
return mysqldumpPath;
}
public void setMysqldumpPath(String mysqldumpPath) {
this.mysqldumpPath = mysqldumpPath;
}
public String getMysqlPath() {
return mysqlPath;
}
public void setMysqlPath(String mysqlPath) {
this.mysqlPath = mysqlPath;
}
public String getRestoreDbScriptPath() {
return restoreDbScriptPath;
}
public void setRestoreDbScriptPath(String restoreDbScriptPath) {
this.restoreDbScriptPath = restoreDbScriptPath;
}
public String getTmsDatabaseName() {
return tmsDatabaseName;
}
public void setTmsDatabaseName(String tmsDatabaseName) {
this.tmsDatabaseName = tmsDatabaseName;
}
public String getCmepDatabaseName() {
return cmepDatabaseName;
}
public void setCmepDatabaseName(String cmepDatabaseName) {
this.cmepDatabaseName = cmepDatabaseName;
}
public String getHealthCheckUrl() {
return healthCheckUrl;
}
public void setHealthCheckUrl(String healthCheckUrl) {
this.healthCheckUrl = healthCheckUrl;
}
public int getHealthCheckTimeoutSeconds() {
return healthCheckTimeoutSeconds;
}
public void setHealthCheckTimeoutSeconds(int healthCheckTimeoutSeconds) {
this.healthCheckTimeoutSeconds = healthCheckTimeoutSeconds;
}
public boolean getRunnerAutoLaunchEnabled() {
return runnerAutoLaunchEnabled;
}
@@ -29,6 +29,11 @@ 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));
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));
@@ -0,0 +1,39 @@
package com.cisd.tms.modules.backup.packagex;
public interface DatabaseBackupCollector {
CollectedDatabaseDump collectTmsDatabaseDump();
CollectedDatabaseDump collectCmepDatabaseDump();
class CollectedDatabaseDump {
private String databaseName;
private String fileName;
private byte[] content;
public String getDatabaseName() {
return databaseName;
}
public void setDatabaseName(String databaseName) {
this.databaseName = databaseName;
}
public String getFileName() {
return fileName;
}
public void setFileName(String fileName) {
this.fileName = fileName;
}
public byte[] getContent() {
return content;
}
public void setContent(byte[] content) {
this.content = content;
}
}
}
@@ -0,0 +1,131 @@
package com.cisd.tms.modules.backup.packagex;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class MysqlDatabaseBackupCollector implements DatabaseBackupCollector {
private final ResourceBackupProperties resourceBackupProperties;
private final String datasourceUrl;
private final String datasourceUsername;
private final String datasourcePassword;
public MysqlDatabaseBackupCollector(
ResourceBackupProperties resourceBackupProperties,
@Value("${spring.datasource.url:}") String datasourceUrl,
@Value("${spring.datasource.username:}") String datasourceUsername,
@Value("${spring.datasource.password:}") String datasourcePassword
) {
this.resourceBackupProperties = resourceBackupProperties;
this.datasourceUrl = datasourceUrl;
this.datasourceUsername = datasourceUsername;
this.datasourcePassword = datasourcePassword;
}
@Override
public CollectedDatabaseDump collectTmsDatabaseDump() {
MysqlConnectionTarget target = resolveTarget(resourceBackupProperties.getTmsDatabaseName(), "TMS");
return collectDatabaseDump(target, "TMS.sql");
}
@Override
public CollectedDatabaseDump collectCmepDatabaseDump() {
MysqlConnectionTarget target = resolveTarget(resourceBackupProperties.getCmepDatabaseName(), "CMEP");
return collectDatabaseDump(target, "CMEP.sql");
}
private CollectedDatabaseDump collectDatabaseDump(MysqlConnectionTarget target, String fileName) {
List<String> command = new ArrayList<>();
command.add(resourceBackupProperties.getMysqldumpPath());
command.add("--host=" + target.host());
command.add("--port=" + target.port());
command.add("--user=" + target.username());
if (!target.password().isEmpty()) {
command.add("--password=" + target.password());
}
command.add("--single-transaction");
command.add("--skip-lock-tables");
command.add("--databases");
command.add(target.databaseName());
try {
Process process = new ProcessBuilder(command).start();
byte[] stdout = readAllBytes(process.getInputStream());
byte[] stderr = readAllBytes(process.getErrorStream());
int exitCode = process.waitFor();
if (exitCode != 0) {
String error = new String(stderr, StandardCharsets.UTF_8).trim();
throw new IllegalStateException("mysqldump failed with exitCode=" + exitCode
+ (error.isEmpty() ? "" : ", error=" + error));
}
if (stdout.length == 0) {
throw new IllegalStateException("mysqldump produced empty output");
}
CollectedDatabaseDump dump = new CollectedDatabaseDump();
dump.setDatabaseName(target.databaseName());
dump.setFileName(fileName);
dump.setContent(stdout);
return dump;
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IllegalStateException("mysqldump interrupted", ex);
} catch (IOException ex) {
throw new IllegalStateException("failed to run mysqldump", ex);
}
}
private MysqlConnectionTarget resolveTarget(String configuredDatabaseName, String fallbackDatabaseName) {
String url = trim(datasourceUrl);
if (url.isEmpty()) {
throw new IllegalStateException("spring.datasource.url is required for database backup");
}
if (!url.startsWith("jdbc:mysql://")) {
throw new IllegalStateException("unsupported datasource url for backup: " + url);
}
try {
String normalizedUrl = url.substring("jdbc:".length());
String[] segments = normalizedUrl.split("\\?", 2);
URI uri = URI.create(segments[0]);
String databaseName = trim(configuredDatabaseName);
if (databaseName.isEmpty()) {
String path = trim(uri.getPath());
databaseName = path.startsWith("/") ? path.substring(1) : path;
}
if (databaseName.isEmpty()) {
databaseName = fallbackDatabaseName;
}
return new MysqlConnectionTarget(
uri.getHost() == null ? "127.0.0.1" : uri.getHost(),
uri.getPort() <= 0 ? 3306 : uri.getPort(),
trim(datasourceUsername),
trim(datasourcePassword),
databaseName
);
} catch (IllegalArgumentException ex) {
throw new IllegalStateException("invalid datasource url for backup: " + url, ex);
}
}
private static byte[] readAllBytes(InputStream inputStream) throws IOException {
try (inputStream; ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
inputStream.transferTo(outputStream);
return outputStream.toByteArray();
}
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
private record MysqlConnectionTarget(String host, int port, String username, String password, String databaseName) {
}
}
@@ -0,0 +1,8 @@
package com.cisd.tms.modules.backup.packagex;
public class RequiredBackupResourceMissingException extends IllegalStateException {
public RequiredBackupResourceMissingException(String message) {
super(message);
}
}
@@ -36,15 +36,18 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
private final ResourceBackupProperties resourceBackupProperties;
private final ResourceEnvelopeCryptoService resourceEnvelopeCryptoService;
private final DatabaseBackupCollector databaseBackupCollector;
private final ObjectMapper objectMapper;
public ResourcePackageServiceImpl(
ResourceBackupProperties resourceBackupProperties,
ResourceEnvelopeCryptoService resourceEnvelopeCryptoService,
DatabaseBackupCollector databaseBackupCollector,
ObjectMapper objectMapper
) {
this.resourceBackupProperties = resourceBackupProperties;
this.resourceEnvelopeCryptoService = resourceEnvelopeCryptoService;
this.databaseBackupCollector = databaseBackupCollector;
this.objectMapper = objectMapper;
}
@@ -243,13 +246,18 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
DiscoveredResourceItem item,
Map<String, Object> resourceEntry
) throws IOException {
if ("DB".equalsIgnoreCase(trim(item.getResourceType()))) {
return collectDatabaseItem(zipOutputStream, item, resourceEntry);
}
if (!"FILE".equalsIgnoreCase(trim(item.getResourceType()))) {
assertOptionalResource(item, "RESOURCE_TYPE_NOT_SUPPORTED_YET");
resourceEntry.put("collected", Boolean.FALSE);
resourceEntry.put("skipReason", "RESOURCE_TYPE_NOT_SUPPORTED_YET");
return 0;
}
Path resolvedPath = toResolvedPath(item.getResolvedPath());
if (resolvedPath == null || !Files.exists(resolvedPath)) {
assertOptionalResource(item, "PATH_NOT_FOUND");
resourceEntry.put("collected", Boolean.FALSE);
resourceEntry.put("skipReason", "PATH_NOT_FOUND");
return 0;
@@ -275,6 +283,7 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
)));
}
} else {
assertOptionalResource(item, "UNSUPPORTED_FILE_TYPE");
resourceEntry.put("collected", Boolean.FALSE);
resourceEntry.put("skipReason", "UNSUPPORTED_FILE_TYPE");
return 0;
@@ -289,6 +298,73 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
return archivedFiles.size();
}
private int collectDatabaseItem(
ZipOutputStream zipOutputStream,
DiscoveredResourceItem item,
Map<String, Object> resourceEntry
) throws IOException {
DatabaseBackupCollector.CollectedDatabaseDump dump = resolveDatabaseDump(item);
if (dump == null) {
assertOptionalResource(item, "RESOURCE_TYPE_NOT_SUPPORTED_YET");
resourceEntry.put("collected", Boolean.FALSE);
resourceEntry.put("skipReason", "RESOURCE_TYPE_NOT_SUPPORTED_YET");
return 0;
}
try {
byte[] content = dump.getContent();
if (content == null || content.length == 0) {
throw new IllegalStateException("database backup collector returned empty dump");
}
String databaseName = trim(dump.getDatabaseName()).isEmpty() ? "TMS" : trim(dump.getDatabaseName());
String fileName = trim(dump.getFileName()).isEmpty() ? "TMS.sql" : trim(dump.getFileName());
String entryPath = "db/" + fileName;
addZipEntry(zipOutputStream, entryPath, content);
Map<String, Object> fileEntry = new LinkedHashMap<>();
fileEntry.put("entryPath", entryPath);
fileEntry.put("sourcePath", "database:" + databaseName);
fileEntry.put("relativeName", fileName);
fileEntry.put("size", content.length);
fileEntry.put("contentSm3", sm3Hex(content));
resourceEntry.put("collected", Boolean.TRUE);
resourceEntry.put("fileCount", 1);
resourceEntry.put("entryPath", entryPath);
resourceEntry.put("databaseName", databaseName);
resourceEntry.put("files", List.of(fileEntry));
return 1;
} catch (RuntimeException ex) {
assertOptionalResource(item, "DB_DUMP_FAILED");
resourceEntry.put("collected", Boolean.FALSE);
resourceEntry.put("skipReason", "DB_DUMP_FAILED");
return 0;
}
}
private DatabaseBackupCollector.CollectedDatabaseDump resolveDatabaseDump(DiscoveredResourceItem item) {
String resourceCode = trim(item.getResourceCode());
if ("TMS_DB_DUMP".equalsIgnoreCase(resourceCode)) {
return databaseBackupCollector.collectTmsDatabaseDump();
}
if ("CMEP_DB_DUMP".equalsIgnoreCase(resourceCode)) {
return databaseBackupCollector.collectCmepDatabaseDump();
}
return null;
}
private static void assertOptionalResource(DiscoveredResourceItem item, String reason) {
if (!item.isRequired()) {
return;
}
String resourceCode = trim(item.getResourceCode()).isEmpty() ? "UNKNOWN" : trim(item.getResourceCode());
String resolvedPath = trim(item.getResolvedPath());
String message = "required backup resource is unavailable: code=" + resourceCode + ", reason=" + reason;
if (!resolvedPath.isEmpty()) {
message += ", path=" + resolvedPath;
}
throw new RequiredBackupResourceMissingException(message);
}
private Map<String, Object> writeRegularFileEntry(
ZipOutputStream zipOutputStream,
DiscoveredResourceItem item,
@@ -1,30 +0,0 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import java.nio.file.Path;
import org.springframework.stereotype.Component;
@Component
public class AsyncResourceRestoreRunnerLauncher implements ResourceRestoreRunnerLauncher {
private final ResourceRestoreRunner resourceRestoreRunner;
private final ResourceBackupProperties resourceBackupProperties;
public AsyncResourceRestoreRunnerLauncher(
ResourceRestoreRunner resourceRestoreRunner,
ResourceBackupProperties resourceBackupProperties
) {
this.resourceRestoreRunner = resourceRestoreRunner;
this.resourceBackupProperties = resourceBackupProperties;
}
@Override
public void launch(Path handoffFile) {
if (!resourceBackupProperties.getRunnerAutoLaunchEnabled()) {
return;
}
Thread thread = new Thread(() -> resourceRestoreRunner.run(handoffFile), "resource-restore-runner");
thread.setDaemon(true);
thread.start();
}
}
@@ -0,0 +1,109 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
@Component
public class ConsistencyVerifyRunnerStepExecutor implements RunnerStepExecutor {
private static final List<String> REQUIRED_SUCCESS_STEPS = List.of(
"RESTORE_FILES",
"RESTORE_DATABASES",
"START_SERVICES"
);
private final ResourceBackupProperties resourceBackupProperties;
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
public ConsistencyVerifyRunnerStepExecutor(
ResourceBackupProperties resourceBackupProperties,
ObjectMapper objectMapper
) {
this(resourceBackupProperties, objectMapper, HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build());
}
ConsistencyVerifyRunnerStepExecutor(
ResourceBackupProperties resourceBackupProperties,
ObjectMapper objectMapper,
HttpClient httpClient
) {
this.resourceBackupProperties = resourceBackupProperties;
this.objectMapper = objectMapper;
this.httpClient = httpClient;
}
@Override
public boolean supports(String stepCode) {
return "CONSISTENCY_VERIFY".equals(stepCode);
}
@Override
public RunnerStepExecutionResult execute(RunnerStepContext context) {
Path stepOutputDir = context.taskDir().resolve("step-output");
for (String requiredStep : REQUIRED_SUCCESS_STEPS) {
assertStepSucceeded(stepOutputDir, requiredStep);
}
verifyHealthEndpoint();
return RunnerStepExecutionResult.success("required steps succeeded and health endpoint is UP");
}
private void assertStepSucceeded(Path stepOutputDir, String stepCode) {
try {
Path stepFile = resolveRequiredStepOutput(stepOutputDir, stepCode);
Map<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);
}
}
}
@@ -0,0 +1,135 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class DatabaseRestoreRunnerStepExecutor implements RunnerStepExecutor {
private final ResourceBackupProperties resourceBackupProperties;
private final RunnerCommandInvoker runnerCommandInvoker;
private final String datasourceUrl;
private final String datasourceUsername;
private final String datasourcePassword;
public DatabaseRestoreRunnerStepExecutor(
ResourceBackupProperties resourceBackupProperties,
RunnerCommandInvoker runnerCommandInvoker,
@Value("${spring.datasource.url:}") String datasourceUrl,
@Value("${spring.datasource.username:}") String datasourceUsername,
@Value("${spring.datasource.password:}") String datasourcePassword
) {
this.resourceBackupProperties = resourceBackupProperties;
this.runnerCommandInvoker = runnerCommandInvoker;
this.datasourceUrl = datasourceUrl;
this.datasourceUsername = datasourceUsername;
this.datasourcePassword = datasourcePassword;
}
@Override
public boolean supports(String stepCode) {
return "RESTORE_DATABASES".equals(stepCode);
}
@Override
public RunnerStepExecutionResult execute(RunnerStepContext context) {
if (!supports(context.stepCode())) {
throw new IllegalArgumentException("unsupported runner step: " + context.stepCode());
}
List<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) {
}
}
@@ -10,10 +10,12 @@ import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.springframework.stereotype.Component;
@@ -21,6 +23,9 @@ import org.springframework.stereotype.Component;
@Component
public class PayloadArchiveRunnerStepExecutor implements RunnerStepExecutor {
private static final Set<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;
@@ -37,7 +42,7 @@ public class PayloadArchiveRunnerStepExecutor implements RunnerStepExecutor {
@Override
public boolean supports(String stepCode) {
return "RESTORE_FILES".equals(stepCode) || "CONSISTENCY_VERIFY".equals(stepCode);
return "RESTORE_FILES".equals(stepCode);
}
@Override
@@ -45,9 +50,6 @@ public class PayloadArchiveRunnerStepExecutor implements RunnerStepExecutor {
if ("RESTORE_FILES".equals(context.stepCode())) {
return restoreFiles(context);
}
if ("CONSISTENCY_VERIFY".equals(context.stepCode())) {
return verifyExtractedPayload(context);
}
throw new IllegalArgumentException("unsupported runner step: " + context.stepCode());
}
@@ -60,17 +62,12 @@ public class PayloadArchiveRunnerStepExecutor implements RunnerStepExecutor {
Path payloadDir = context.taskDir().resolve("staging").resolve("payload").normalize();
extractPayloadArchive(payloadArchiveBytes, payloadDir);
int resourceCount = readResourceCount(payloadDir.resolve("payload-manifest.json"));
return RunnerStepExecutionResult.success("payload extracted to staging, resourceCount=" + resourceCount);
}
private RunnerStepExecutionResult verifyExtractedPayload(RunnerStepContext context) {
Path manifestFile = context.taskDir().resolve("staging").resolve("payload").resolve("payload-manifest.json").normalize();
if (!Files.isRegularFile(manifestFile)) {
throw new IllegalStateException("payload manifest missing: " + manifestFile);
}
int resourceCount = readResourceCount(manifestFile);
return RunnerStepExecutionResult.success("payload manifest verified, resourceCount=" + resourceCount);
Path payloadManifestFile = payloadDir.resolve("payload-manifest.json");
int resourceCount = readResourceCount(payloadManifestFile);
int restoredFileCount = restoreSupportedFiles(payloadDir, payloadManifestFile);
return RunnerStepExecutionResult.success(
"payload extracted to staging, resourceCount=" + resourceCount + ", restoredFileCount=" + restoredFileCount
);
}
private ResourceRestoreHandoffSnapshot readRequiredHandoff(Path handoffFile) {
@@ -133,6 +130,64 @@ public class PayloadArchiveRunnerStepExecutor implements RunnerStepExecutor {
}
}
private int restoreSupportedFiles(Path payloadDir, Path manifestFile) {
try {
Map<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();
}
@@ -0,0 +1,41 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import java.nio.file.Path;
import java.util.List;
import org.springframework.stereotype.Component;
@Component
public class ProcessResourceRestoreRunnerLauncher implements ResourceRestoreRunnerLauncher {
private final ResourceBackupProperties resourceBackupProperties;
private final RunnerCommandInvoker runnerCommandInvoker;
public ProcessResourceRestoreRunnerLauncher(
ResourceBackupProperties resourceBackupProperties,
RunnerCommandInvoker runnerCommandInvoker
) {
this.resourceBackupProperties = resourceBackupProperties;
this.runnerCommandInvoker = runnerCommandInvoker;
}
@Override
public void launch(Path handoffFile) {
if (!resourceBackupProperties.getRunnerAutoLaunchEnabled()) {
return;
}
Path normalizedHandoffFile = handoffFile.normalize().toAbsolutePath();
Path taskDir = normalizedHandoffFile.getParent();
Path launcherLog = taskDir.resolve("runner-launch.log");
List<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);
}
}
}
@@ -0,0 +1,38 @@
package com.cisd.tms.modules.backup.runner;
import com.cisd.tms.TmsApplication;
import java.nio.file.Path;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.env.Environment;
public final class ResourceRestoreRunnerMain {
private ResourceRestoreRunnerMain() {
}
public static void main(String[] args) {
ConfigurableApplicationContext context = new SpringApplicationBuilder(TmsApplication.class)
.web(WebApplicationType.NONE)
.logStartupInfo(false)
.run(args);
int exitCode = 0;
try {
Environment environment = context.getEnvironment();
String handoffFile = environment.getProperty("tms.backup.runner-handoff-file", "");
if (handoffFile == null || handoffFile.isBlank()) {
throw new IllegalStateException("tms.backup.runner-handoff-file is required");
}
context.getBean(ResourceRestoreRunner.class).run(Path.of(handoffFile));
} catch (RuntimeException ex) {
exitCode = 1;
throw ex;
} finally {
final int finalExitCode = exitCode;
int springExitCode = SpringApplication.exit(context, () -> finalExitCode);
System.exit(springExitCode);
}
}
}
@@ -10,6 +10,7 @@ import com.cisd.tms.modules.backup.dto.response.ResourceKeysetStatusResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepItemResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepLogResponse;
import com.cisd.tms.modules.backup.entity.ResourceBackupTaskEntity;
import com.cisd.tms.modules.backup.packagex.RequiredBackupResourceMissingException;
import com.cisd.tms.modules.backup.packagex.ResourcePackageResult;
import com.cisd.tms.modules.backup.packagex.ResourcePackageService;
import com.cisd.tms.modules.backup.repository.ResourceBackupTaskRepository;
@@ -62,45 +63,56 @@ public class ResourceBackupServiceImpl implements ResourceBackupService {
String taskId = resourceTaskIdGenerator.nextBackupTaskId();
String backupId = resourceTaskIdGenerator.nextBackupId();
// 先根据初始化快照收敛资源范围,再交给打包服务生成本地可见的 .tmsbak 文件。
ResourcePackageResult packageResult = resourcePackageService.createPackage(
taskId,
backupId,
initTask,
resourceDiscoveryService.discover(initTask),
request
);
LocalDateTime now = LocalDateTime.now();
ResourceBackupTaskEntity entity = new ResourceBackupTaskEntity();
// 第一版先按同步成功模型落库,后续切换为真正异步任务时继续复用这张任务表。
entity.setTaskId(taskId);
entity.setBackupId(backupId);
entity.setStatus("SUCCESS");
entity.setStatus("RUNNING");
entity.setProductType(initTask.getProductType());
entity.setMqType(initTask.getMqType());
entity.setOrgCode(initTask.getOrgCode());
entity.setSourceDeviceId(resolveSourceDeviceId());
entity.setPackageName(packageResult.getPackageName());
entity.setPackagePath(packageResult.getPackagePath());
entity.setPackageSize(packageResult.getPackageSize());
entity.setPackageHash(packageResult.getPackageHash());
entity.setStorageEncKeyFingerprint(packageResult.getStorageEncKeyFingerprint());
entity.setAuthenticityKeyFingerprint(packageResult.getAuthenticityKeyFingerprint());
entity.setManifestSummaryJson(packageResult.getManifestSummaryJson());
entity.setRemark(request == null ? null : request.getRemark());
entity.setCreateBy("SYSTEM");
entity.setCreateTime(now);
entity.setUpdateTime(now);
entity.setStartTime(now);
entity.setFinishTime(now);
try {
// 先根据初始化快照收敛资源范围,再交给打包服务生成本地可见的 .tmsbak 文件。
ResourcePackageResult packageResult = resourcePackageService.createPackage(
taskId,
backupId,
initTask,
resourceDiscoveryService.discover(initTask),
request
);
// 第一版先按同步模型直接落最终结果,后续切换异步任务时仍复用同一张任务表。
entity.setStatus("SUCCESS");
entity.setPackageName(packageResult.getPackageName());
entity.setPackagePath(packageResult.getPackagePath());
entity.setPackageSize(packageResult.getPackageSize());
entity.setPackageHash(packageResult.getPackageHash());
entity.setStorageEncKeyFingerprint(packageResult.getStorageEncKeyFingerprint());
entity.setAuthenticityKeyFingerprint(packageResult.getAuthenticityKeyFingerprint());
entity.setManifestSummaryJson(packageResult.getManifestSummaryJson());
} catch (RequiredBackupResourceMissingException ex) {
entity.setStatus("FAILED");
entity.setErrorCode("REQUIRED_RESOURCE_MISSING");
entity.setErrorMessage(ex.getMessage());
} catch (RuntimeException ex) {
entity.setStatus("FAILED");
entity.setErrorCode("PACKAGE_CREATE_FAILED");
entity.setErrorMessage(ex.getMessage());
}
entity.setFinishTime(LocalDateTime.now());
resourceBackupTaskRepository.save(entity);
CreateResourceBackupResponse response = new CreateResourceBackupResponse();
response.setTaskId(taskId);
response.setBackupId(backupId);
response.setStatus(entity.getStatus());
response.setCreatedAt(now.toString());
response.setCreatedAt(entity.getCreateTime().toString());
return response;
}
+16
View File
@@ -186,6 +186,22 @@ tms:
output-dir: ${TMS_BACKUP_OUTPUT_DIR:/home/tms/tmp/resource-backup-packages}
# 恢复 runner 启停 TMS 时使用的脚本路径。
tms-script-path: ${TMS_BACKUP_TMS_SCRIPT_PATH:/home/tms/scripts/tms.sh}
# 外部恢复 runner 启动脚本路径;创建恢复任务后由主进程调用该脚本拉起独立 runner。
runner-launch-script-path: ${TMS_BACKUP_RUNNER_LAUNCH_SCRIPT_PATH:/home/tms/bin/resource-restore/start-runner.sh}
# 导出 TMS 库时使用的 mysqldump 命令;现场可按实际安装路径覆盖。
mysqldump-path: ${TMS_BACKUP_MYSQLDUMP_PATH:mysqldump}
# 恢复 TMS 库时使用的 mysql 客户端命令。
mysql-path: ${TMS_BACKUP_MYSQL_PATH:mysql}
# 数据库恢复脚本路径;runner 在 RESTORE_DATABASES 阶段调用。
restore-db-script-path: ${TMS_BACKUP_RESTORE_DB_SCRIPT_PATH:/home/tms/bin/resource-restore/restore-db.sh}
# 备份 TMS 库名;为空时默认从 spring.datasource.url 中解析。
tms-database-name: ${TMS_BACKUP_TMS_DATABASE_NAME:}
# 备份/恢复标准收发器业务库名;第一版默认使用 CMEP。
cmep-database-name: ${TMS_BACKUP_CMEP_DATABASE_NAME:CMEP}
# 恢复完成后的最小健康检查地址。
health-check-url: ${TMS_BACKUP_HEALTH_CHECK_URL:http://127.0.0.1:8080/actuator/health}
# 健康检查请求超时(秒)。
health-check-timeout-seconds: ${TMS_BACKUP_HEALTH_CHECK_TIMEOUT_SECONDS:5}
# 是否在创建恢复任务后自动拉起本机 runner;默认关闭,避免开发环境误触停机恢复链。
runner-auto-launch-enabled: ${TMS_BACKUP_RUNNER_AUTO_LAUNCH_ENABLED:false}
# 恢复 runner 心跳失联超时(秒)。