fix:排除资源备份表

This commit is contained in:
waner
2026-05-11 17:45:07 +08:00
parent d1f136f8e9
commit 750c08f202
10 changed files with 340 additions and 29 deletions
@@ -2,12 +2,19 @@ package com.cisd.tms.modules.backup.config;
import com.cisd.tms.modules.backup.dto.support.ConfiguredFileBackupResource;
import com.cisd.tms.modules.backup.dto.support.ConfiguredRestoreCommand;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "tms.backup")
public class ResourceBackupProperties {
private static final List<String> MANDATORY_TMS_DB_EXCLUDED_TABLES = List.of(
"tms_resource_backup_task",
"tms_resource_restore_task"
);
private String outputDir = "/home/tms/tmp/resource-backup-packages";
private String precheckStoreDir = "/home/tms/tmp/resource-restore-precheck";
private String restoreTaskRootDir = "/home/tms/tmp/resource-restore";
@@ -16,6 +23,7 @@ public class ResourceBackupProperties {
private int mysqldumpTimeoutSeconds = 300;
private String mysqlPath = "mysql";
private String restoreDbScriptPath = "/home/tms/bin/resource-restore/restore-db.sh";
private int restoreDbTimeoutSeconds = 300;
private String mqReplayScriptPath = "/home/tms/bin/resource-restore/replay-mq.sh";
private String tmsDatabaseName = "";
private List<String> tmsDbExcludedTables = List.of(
@@ -25,7 +33,9 @@ public class ResourceBackupProperties {
"tms_auth_session",
"tms_auth_challenge",
"tms_auth_audit_log",
"tms_operation_audit_log"
"tms_operation_audit_log",
"tms_resource_backup_task",
"tms_resource_restore_task"
);
private String cmepDatabaseName = "CMEP";
private String healthCheckUrl = "http://127.0.0.1:8080/actuator/health";
@@ -107,6 +117,14 @@ public class ResourceBackupProperties {
this.restoreDbScriptPath = restoreDbScriptPath;
}
public int getRestoreDbTimeoutSeconds() {
return restoreDbTimeoutSeconds;
}
public void setRestoreDbTimeoutSeconds(int restoreDbTimeoutSeconds) {
this.restoreDbTimeoutSeconds = restoreDbTimeoutSeconds;
}
public String getMqReplayScriptPath() {
return mqReplayScriptPath;
}
@@ -124,7 +142,12 @@ public class ResourceBackupProperties {
}
public List<String> getTmsDbExcludedTables() {
return tmsDbExcludedTables;
LinkedHashSet<String> values = new LinkedHashSet<>();
if (tmsDbExcludedTables != null) {
values.addAll(tmsDbExcludedTables);
}
values.addAll(MANDATORY_TMS_DB_EXCLUDED_TABLES);
return new ArrayList<>(values);
}
public void setTmsDbExcludedTables(List<String> tmsDbExcludedTables) {
@@ -79,45 +79,40 @@ public class MysqlDatabaseBackupCollector implements DatabaseBackupCollector {
command.add(target.databaseName());
try {
Process process = new ProcessBuilder(command)
.redirectErrorStream(true)
.start();
AtomicReference<byte[]> outputRef = new AtomicReference<>(new byte[0]);
AtomicReference<IOException> outputErrorRef = new AtomicReference<>();
Thread outputReader = new Thread(() -> {
try {
outputRef.set(readAllBytes(process.getInputStream()));
} catch (IOException ex) {
outputErrorRef.set(ex);
}
}, "resource-backup-mysqldump-output-reader");
outputReader.setDaemon(true);
outputReader.start();
Process process = new ProcessBuilder(command).start();
StreamReadResult stdout = readStreamAsync(process.getInputStream(), "resource-backup-mysqldump-stdout-reader");
StreamReadResult stderr = readStreamAsync(process.getErrorStream(), "resource-backup-mysqldump-stderr-reader");
int timeoutSeconds = Math.max(1, resourceBackupProperties.getMysqldumpTimeoutSeconds());
boolean finished = process.waitFor(timeoutSeconds, java.util.concurrent.TimeUnit.SECONDS);
if (!finished) {
process.destroyForcibly();
joinOutputReader(outputReader);
joinOutputReader(stdout.thread());
joinOutputReader(stderr.thread());
throw new IllegalStateException("mysqldump执行超时 after " + timeoutSeconds + "s");
}
joinOutputReader(outputReader);
if (outputErrorRef.get() != null) {
throw new IllegalStateException("读取mysqldump输出失败", outputErrorRef.get());
joinOutputReader(stdout.thread());
joinOutputReader(stderr.thread());
if (stdout.error().get() != null) {
throw new IllegalStateException("读取mysqldump标准输出失败", stdout.error().get());
}
byte[] stdout = outputRef.get();
if (stderr.error().get() != null) {
throw new IllegalStateException("读取mysqldump错误输出失败", stderr.error().get());
}
byte[] stdoutBytes = stdout.bytes().get();
byte[] stderrBytes = stderr.bytes().get();
int exitCode = process.exitValue();
if (exitCode != 0) {
String error = new String(stdout, StandardCharsets.UTF_8).trim();
String error = new String(stderrBytes, StandardCharsets.UTF_8).trim();
throw new IllegalStateException("mysqldump 执行失败 with exitCode=" + exitCode
+ (error.isEmpty() ? "" : ", error=" + error));
}
if (stdout.length == 0) {
if (stdoutBytes.length == 0) {
throw new IllegalStateException("mysqldump输出为空");
}
CollectedDatabaseDump dump = new CollectedDatabaseDump();
dump.setDatabaseName(target.databaseName());
dump.setFileName(fileName);
dump.setContent(stdout);
dump.setContent(stdoutBytes);
return dump;
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
@@ -131,6 +126,21 @@ public class MysqlDatabaseBackupCollector implements DatabaseBackupCollector {
outputReader.join(1000L);
}
private static StreamReadResult readStreamAsync(InputStream inputStream, String threadName) {
AtomicReference<byte[]> bytes = new AtomicReference<>(new byte[0]);
AtomicReference<IOException> error = new AtomicReference<>();
Thread thread = new Thread(() -> {
try {
bytes.set(readAllBytes(inputStream));
} catch (IOException ex) {
error.set(ex);
}
}, threadName);
thread.setDaemon(true);
thread.start();
return new StreamReadResult(thread, bytes, error);
}
private MysqlConnectionTarget resolveTarget(String configuredDatabaseName, String fallbackDatabaseName) {
String url = trim(datasourceUrl);
if (url.isEmpty()) {
@@ -187,4 +197,11 @@ public class MysqlDatabaseBackupCollector implements DatabaseBackupCollector {
private record MysqlConnectionTarget(String host, int port, String username, String password, String databaseName) {
}
private record StreamReadResult(
Thread thread,
AtomicReference<byte[]> bytes,
AtomicReference<IOException> error
) {
}
}
@@ -113,6 +113,7 @@ public class LightweightRestoreApplierLauncher {
// 脚本读取这些变量后执行停服务、导库、MQ replay、健康检查等现场动作。
values.put("TMS_SCRIPT_PATH", trim(properties.getTmsScriptPath()));
values.put("DB_RESTORE_SCRIPT_PATH", trim(properties.getRestoreDbScriptPath()));
values.put("RESTORE_DB_TIMEOUT_SECONDS", String.valueOf(properties.getRestoreDbTimeoutSeconds()));
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());
@@ -28,7 +28,10 @@ class ResourceBackupPropertiesTest {
Assertions.assertTrue(properties.getTmsDbExcludedTables().contains("tms_role_account"));
Assertions.assertTrue(properties.getTmsDbExcludedTables().contains("tms_role_ukey_binding"));
Assertions.assertTrue(properties.getTmsDbExcludedTables().contains("tms_auth_session"));
Assertions.assertTrue(properties.getTmsDbExcludedTables().contains("tms_resource_backup_task"));
Assertions.assertTrue(properties.getTmsDbExcludedTables().contains("tms_resource_restore_task"));
Assertions.assertEquals(300, properties.getMysqldumpTimeoutSeconds());
Assertions.assertEquals(300, properties.getRestoreDbTimeoutSeconds());
Assertions.assertEquals(180, properties.getHealthCheckMaxWaitSeconds());
Assertions.assertEquals(5, properties.getHealthCheckRetryIntervalSeconds());
}
@@ -52,6 +55,17 @@ class ResourceBackupPropertiesTest {
Assertions.assertFalse(properties.getFileResources().get(0).isRequired());
}
@Test
void shouldAlwaysExcludeResourceTaskTablesEvenWhenConfiguredListOverridesDefaults() {
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setTmsDbExcludedTables(java.util.List.of("custom_table"));
Assertions.assertEquals("custom_table", properties.getTmsDbExcludedTables().get(0));
Assertions.assertTrue(properties.getTmsDbExcludedTables().contains("tms_resource_backup_task"));
Assertions.assertTrue(properties.getTmsDbExcludedTables().contains("tms_resource_restore_task"));
}
@Test
void shouldAcceptConfiguredRestoreStartAndStopCommands() {
ResourceBackupProperties properties = new ResourceBackupProperties();
@@ -68,6 +68,45 @@ class MysqlDatabaseBackupCollectorTest {
Assertions.assertTrue(elapsedMillis < 1800, "mysqldump should be killed on timeout");
}
@Test
void shouldKeepMysqldumpWarningsOutOfSqlDumpContent() throws Exception {
Path mysqldump = noisyMysqldump();
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setMysqldumpPath(mysqldump.toString());
MysqlDatabaseBackupCollector collector = new MysqlDatabaseBackupCollector(
properties,
"jdbc:mysql://127.0.0.1:4000/TMS?useSSL=false",
"root",
"secret"
);
DatabaseBackupCollector.CollectedDatabaseDump dump = collector.collectTmsDatabaseDump();
String content = new String(dump.getContent());
Assertions.assertEquals("-- synthetic dump\n", content);
Assertions.assertFalse(content.contains("mysqldump: [Warning]"));
}
@Test
void shouldExcludeResourceTaskTablesByDefaultWhenDumpingTmsDatabase() throws Exception {
Path argsLog = tempDir.resolve("default-mysqldump-args.log");
Path mysqldump = fakeMysqldump(argsLog);
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setMysqldumpPath(mysqldump.toString());
MysqlDatabaseBackupCollector collector = new MysqlDatabaseBackupCollector(
properties,
"jdbc:mysql://127.0.0.1:4000/TMS?useSSL=false",
"root",
"secret"
);
collector.collectTmsDatabaseDump();
String invocation = Files.readString(argsLog);
Assertions.assertTrue(invocation.contains("--ignore-table=TMS.tms_resource_backup_task"));
Assertions.assertTrue(invocation.contains("--ignore-table=TMS.tms_resource_restore_task"));
}
private Path fakeMysqldump(Path argsLog) throws Exception {
Path script = tempDir.resolve("mysqldump");
Files.writeString(script, """
@@ -89,4 +128,15 @@ class MysqlDatabaseBackupCollectorTest {
script.toFile().setExecutable(true);
return script;
}
private Path noisyMysqldump() throws Exception {
Path script = tempDir.resolve("noisy-mysqldump");
Files.writeString(script, """
#!/usr/bin/env bash
printf 'mysqldump: [Warning] Using a password on the command line interface can be insecure.\\n' >&2
printf -- '-- synthetic dump\\n'
""");
script.toFile().setExecutable(true);
return script;
}
}
@@ -193,7 +193,111 @@ class ApplyResourceBackupScriptContractTest {
Assertions.assertTrue(log.indexOf("start-tms.sh") < log.indexOf("start-standard-apps.sh"));
}
@Test
void shouldAttemptServiceStartWhenFileRestoreFailsAfterStop() throws Exception {
Path workDir = tempDir.resolve("work");
Path payloadDir = workDir.resolve("payload");
Files.createDirectories(payloadDir);
Files.writeString(payloadDir.resolve("payload-manifest.json"), "{\"resources\":[]}");
Files.writeString(workDir.resolve("restore-files.tsv"),
"resources/missing.yml\t" + tempDir.resolve("target/application.yml") + System.lineSeparator());
Path commandLog = tempDir.resolve("service-recovery.log");
Path stopScript = fakeScript("stop-tms.sh", commandLog);
Path startScript = fakeScript("start-tms.sh", commandLog);
Files.writeString(workDir.resolve("restore-stop-commands.tsv"),
"TMS\ttrue\t" + stopScript + System.lineSeparator());
Files.writeString(workDir.resolve("restore-start-commands.tsv"),
"TMS\ttrue\t" + startScript + System.lineSeparator());
int exitCode = runScriptForExit(workDir, Map.of());
Assertions.assertEquals(1, exitCode);
String log = Files.readString(commandLog);
Assertions.assertTrue(log.indexOf("stop-tms.sh") < log.indexOf("start-tms.sh"));
String status = Files.readString(workDir.resolve("status.json"));
Assertions.assertTrue(status.contains("\"status\":\"FAILED\""));
Assertions.assertTrue(status.contains("\"phase\":\"FILE\""));
}
@Test
void shouldFailAndAttemptServiceStartWhenDatabaseRestoreTimesOut() throws Exception {
Path workDir = tempDir.resolve("work");
Path payloadDir = workDir.resolve("payload");
Files.createDirectories(payloadDir.resolve("db"));
Files.writeString(payloadDir.resolve("payload-manifest.json"), "{\"resources\":[]}");
Files.writeString(payloadDir.resolve("db/TMS.sql"), "select 1;");
Files.writeString(workDir.resolve("restore-databases.tsv"), "db/TMS.sql\tTMS" + System.lineSeparator());
Path commandLog = tempDir.resolve("db-timeout-recovery.log");
Path stopScript = fakeScript("stop-tms.sh", commandLog);
Path startScript = fakeScript("start-tms.sh", commandLog);
Path dbScript = slowScript("db-restore.sh", commandLog);
Files.writeString(workDir.resolve("restore-stop-commands.tsv"),
"TMS\ttrue\t" + stopScript + System.lineSeparator());
Files.writeString(workDir.resolve("restore-start-commands.tsv"),
"TMS\ttrue\t" + startScript + System.lineSeparator());
int exitCode = runScriptForExit(workDir, Map.of(
"DB_RESTORE_SCRIPT_PATH", dbScript.toString(),
"MYSQL_PATH", "mysql-test",
"DB_HOST", "127.0.0.1",
"DB_PORT", "4000",
"DB_USER", "root",
"DB_PASSWORD", "secret",
"RESTORE_DB_TIMEOUT_SECONDS", "1"
));
Assertions.assertEquals(1, exitCode);
String log = Files.readString(commandLog);
Assertions.assertTrue(log.indexOf("stop-tms.sh") < log.indexOf("db-restore.sh"));
Assertions.assertTrue(log.indexOf("db-restore.sh") < log.indexOf("start-tms.sh"));
String status = Files.readString(workDir.resolve("status.json"));
Assertions.assertTrue(status.contains("\"status\":\"FAILED\""));
Assertions.assertTrue(status.contains("\"phase\":\"DATABASE\""));
Assertions.assertTrue(status.contains("timed out"));
}
@Test
void shouldFailAndAttemptServiceStartWhenDatabaseRestoreCommandFails() throws Exception {
Path workDir = tempDir.resolve("work");
Path payloadDir = workDir.resolve("payload");
Files.createDirectories(payloadDir.resolve("db"));
Files.writeString(payloadDir.resolve("payload-manifest.json"), "{\"resources\":[]}");
Files.writeString(payloadDir.resolve("db/TMS.sql"), "bad sql");
Files.writeString(workDir.resolve("restore-databases.tsv"), "db/TMS.sql\tTMS" + System.lineSeparator());
Path commandLog = tempDir.resolve("db-failure-recovery.log");
Path stopScript = fakeScript("stop-tms.sh", commandLog);
Path startScript = fakeScript("start-tms.sh", commandLog);
Path dbScript = failingScript("db-restore.sh", commandLog);
Files.writeString(workDir.resolve("restore-stop-commands.tsv"),
"TMS\ttrue\t" + stopScript + System.lineSeparator());
Files.writeString(workDir.resolve("restore-start-commands.tsv"),
"TMS\ttrue\t" + startScript + System.lineSeparator());
int exitCode = runScriptForExit(workDir, Map.of(
"DB_RESTORE_SCRIPT_PATH", dbScript.toString(),
"MYSQL_PATH", "mysql-test",
"DB_HOST", "127.0.0.1",
"DB_PORT", "4000",
"DB_USER", "root",
"DB_PASSWORD", "secret"
));
Assertions.assertEquals(1, exitCode);
String log = Files.readString(commandLog);
Assertions.assertTrue(log.indexOf("stop-tms.sh") < log.indexOf("db-restore.sh"));
Assertions.assertTrue(log.indexOf("db-restore.sh") < log.indexOf("start-tms.sh"));
String status = Files.readString(workDir.resolve("status.json"));
Assertions.assertTrue(status.contains("\"status\":\"FAILED\""));
Assertions.assertTrue(status.contains("\"phase\":\"DATABASE\""));
Assertions.assertTrue(status.contains("database restore failed"));
}
private static void runScript(Path workDir, Map<String, String> environment) throws Exception {
int exitCode = runScriptForExit(workDir, environment);
Assertions.assertEquals(0, exitCode);
}
private static int runScriptForExit(Path workDir, Map<String, String> environment) throws Exception {
ProcessBuilder builder = new ProcessBuilder(
"bash",
Path.of("scripts/resource-restore/apply-resource-backup.sh").toAbsolutePath().normalize().toString(),
@@ -203,8 +307,7 @@ class ApplyResourceBackupScriptContractTest {
builder.directory(Path.of(".").toAbsolutePath().normalize().toFile());
builder.environment().putAll(environment);
Process process = builder.start();
int exitCode = process.waitFor();
Assertions.assertEquals(0, exitCode, new String(process.getErrorStream().readAllBytes()));
return process.waitFor();
}
private Path fakeScript(String name, Path commandLog) throws Exception {
@@ -216,4 +319,26 @@ class ApplyResourceBackupScriptContractTest {
script.toFile().setExecutable(true);
return script;
}
private Path slowScript(String name, Path commandLog) throws Exception {
Path script = tempDir.resolve(name);
Files.writeString(script, """
#!/usr/bin/env bash
printf '%%s %%s\\n' "$0" "$*" >> "%s"
sleep 2
""".formatted(commandLog));
script.toFile().setExecutable(true);
return script;
}
private Path failingScript(String name, Path commandLog) throws Exception {
Path script = tempDir.resolve(name);
Files.writeString(script, """
#!/usr/bin/env bash
printf '%%s %%s\\n' "$0" "$*" >> "%s"
exit 2
""".formatted(commandLog));
script.toFile().setExecutable(true);
return script;
}
}
@@ -61,6 +61,7 @@ class LightweightRestoreApplierLauncherTest {
String env = Files.readString(workDir.toAbsolutePath().normalize().resolve("restore-env.sh"));
Assertions.assertTrue(env.contains("export DB_RESTORE_SCRIPT_PATH='/home/tms/bin/resource-restore/restore-db.sh'"));
Assertions.assertTrue(env.contains("export RESTORE_DB_TIMEOUT_SECONDS='300'"));
Assertions.assertTrue(env.contains("export MQ_REPLAY_SCRIPT_PATH='/home/tms/bin/resource-restore/replay-mq.sh'"));
Assertions.assertTrue(env.contains("export MYSQL_PATH='mysql'"));
Assertions.assertTrue(env.contains("export DB_HOST='db-host'"));