refactor: simplify architecture and harden lifecycle

Remove obsolete implementations, centralize background task ownership and terminal-state recovery, consolidate frontend routing and log streaming, and enforce project-wide verification in CI.
This commit is contained in:
Awuqing
2026-08-26 11:32:00 +08:00
parent 95b27af600
commit c06bbec383
189 changed files with 8983 additions and 4264 deletions
@@ -1,37 +0,0 @@
//go:build ignore
package backup
import (
"context"
"io"
"os"
"os/exec"
)
type CommandExecutor interface {
LookPath(file string) (string, error)
Run(ctx context.Context, name string, args []string, env map[string]string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error
}
type OSCommandExecutor struct{}
func NewOSCommandExecutor() *OSCommandExecutor {
return &OSCommandExecutor{}
}
func (e *OSCommandExecutor) LookPath(file string) (string, error) {
return exec.LookPath(file)
}
func (e *OSCommandExecutor) Run(ctx context.Context, name string, args []string, env map[string]string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error {
command := exec.CommandContext(ctx, name, args...)
command.Stdin = stdin
command.Stdout = stdout
command.Stderr = stderr
command.Env = os.Environ()
for key, value := range env {
command.Env = append(command.Env, key+"="+value)
}
return command.Run()
}
@@ -0,0 +1,145 @@
package backup
import (
"context"
"io"
"os"
"reflect"
"testing"
)
type capturingLogWriter struct {
lines []string
}
func (w *capturingLogWriter) WriteLine(message string) {
w.lines = append(w.lines, message)
}
func TestLogLineWriterHandlesFragmentedWrites(t *testing.T) {
log := &capturingLogWriter{}
w := newLogLineWriter(log, "tool")
assertWrite := func(chunk string) {
t.Helper()
n, err := w.Write([]byte(chunk))
if err != nil || n != len(chunk) {
t.Fatalf("Write(%q) = (%d, %v), want (%d, nil)", chunk, n, err, len(chunk))
}
}
assertWrite("fir")
if len(log.lines) != 0 || string(w.pending) != "fir" {
t.Fatalf("first fragment logged or buffered incorrectly: lines=%#v pending=%q", log.lines, w.pending)
}
assertWrite("st\nsec")
if !reflect.DeepEqual(log.lines, []string{"[tool] first"}) || string(w.pending) != "sec" {
t.Fatalf("second fragment handled incorrectly: lines=%#v pending=%q", log.lines, w.pending)
}
assertWrite("ond\n")
if !reflect.DeepEqual(log.lines, []string{"[tool] first", "[tool] second"}) || len(w.pending) != 0 {
t.Fatalf("completed fragments handled incorrectly: lines=%#v pending=%q", log.lines, w.pending)
}
if got := w.collected(); got != "first\nsecond" {
t.Fatalf("collected() = %q, want complete raw output", got)
}
}
func TestLogLineWriterEmitsMultipleCompleteLinesOnce(t *testing.T) {
log := &capturingLogWriter{}
w := newLogLineWriter(log, "tool")
n, err := w.Write([]byte("one\ntwo\n\n three \r\n"))
if err != nil || n != len("one\ntwo\n\n three \r\n") {
t.Fatalf("Write() = (%d, %v)", n, err)
}
want := []string{"[tool] one", "[tool] two", "[tool] three"}
if !reflect.DeepEqual(log.lines, want) {
t.Fatalf("lines = %#v, want %#v", log.lines, want)
}
if len(w.pending) != 0 {
t.Fatalf("complete input left pending bytes: %q", w.pending)
}
}
func TestLogLineWriterFlushIsIdempotentAndPreservesCollection(t *testing.T) {
log := &capturingLogWriter{}
w := newLogLineWriter(log, "tool")
_, _ = w.Write([]byte("complete\n tail "))
w.Flush()
w.Flush()
want := []string{"[tool] complete", "[tool] tail"}
if !reflect.DeepEqual(log.lines, want) {
t.Fatalf("lines after repeated Flush = %#v, want %#v", log.lines, want)
}
if len(w.pending) != 0 {
t.Fatalf("Flush left pending bytes: %q", w.pending)
}
if got := w.collected(); got != "complete\n tail" {
t.Fatalf("collected() = %q, want complete stderr independent of Flush", got)
}
}
func TestPostgreSQLRunnerFlushesEachCommandTail(t *testing.T) {
executor := &fakeCommandExecutor{runFunc: func(_ string, args []string, options CommandOptions) error {
name := args[len(args)-1]
_, _ = io.WriteString(options.Stdout, name)
_, _ = io.WriteString(options.Stderr, "warning "+name)
return nil
}}
log := &capturingLogWriter{}
runner := NewPostgreSQLRunner(executor)
result, err := runner.Run(context.Background(), TaskSpec{
Name: "pg-log-lines",
TempDir: t.TempDir(),
Database: DatabaseSpec{
Host: "127.0.0.1", Port: 5432, User: "postgres", Names: []string{"app", "audit"},
},
}, log)
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(result.TempDir) })
for _, expected := range []string{"[pg_dump] warning app", "[pg_dump] warning audit"} {
count := 0
for _, line := range log.lines {
if line == expected {
count++
}
}
if count != 1 {
t.Fatalf("line %q occurred %d times in %#v", expected, count, log.lines)
}
}
}
func TestMongoDBRunnerFlushesUnterminatedStderr(t *testing.T) {
executor := &fakeCommandExecutor{runFunc: func(_ string, _ []string, options CommandOptions) error {
_, _ = io.WriteString(options.Stdout, "archive")
_, _ = io.WriteString(options.Stderr, "tail warning")
return nil
}}
log := &capturingLogWriter{}
runner := NewMongoDBRunner(executor)
result, err := runner.Run(context.Background(), TaskSpec{
Name: "mongo-log-tail",
Database: DatabaseSpec{
Host: "127.0.0.1", Port: 27017, Names: []string{"app"},
},
}, log)
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(result.TempDir) })
count := 0
for _, line := range log.lines {
if line == "[mongodump] tail warning" {
count++
}
}
if count != 1 {
t.Fatalf("unterminated stderr line occurred %d times in %#v", count, log.lines)
}
}
+4 -2
View File
@@ -62,8 +62,10 @@ func (r *MongoDBRunner) Run(ctx context.Context, task TaskSpec, writer LogWriter
writer.WriteLine(fmt.Sprintf("连接到 MongoDB: %s:%d", task.Database.Host, task.Database.Port))
stderrWriter := newLogLineWriter(writer, "mongodump")
writer.WriteLine("开始执行 mongodump")
if err := r.executor.Run(ctx, "mongodump", args, CommandOptions{Stdout: file, Stderr: stderrWriter}); err != nil {
return nil, fmt.Errorf("run mongodump: %w: %s", err, stderrWriter.collected())
runErr := r.executor.Run(ctx, "mongodump", args, CommandOptions{Stdout: file, Stderr: stderrWriter})
stderrWriter.Flush()
if runErr != nil {
return nil, fmt.Errorf("run mongodump: %w: %s", runErr, stderrWriter.collected())
}
info, err := file.Stat()
if err != nil {
+39 -22
View File
@@ -1,7 +1,6 @@
package backup
import (
"bufio"
"bytes"
"context"
"fmt"
@@ -70,8 +69,10 @@ func (r *MySQLRunner) Run(ctx context.Context, task TaskSpec, writer LogWriter)
stderrWriter := newLogLineWriter(writer, "mysqldump")
writer.WriteLine("开始执行 mysqldump")
if err := r.executor.Run(ctx, "mysqldump", args, CommandOptions{Stdout: file, Stderr: stderrWriter, Env: mysqlEnv(task.Database.Password)}); err != nil {
return nil, fmt.Errorf("run mysqldump: %w: %s", err, stderrWriter.collected())
runErr := r.executor.Run(ctx, "mysqldump", args, CommandOptions{Stdout: file, Stderr: stderrWriter, Env: mysqlEnv(task.Database.Password)})
stderrWriter.Flush()
if runErr != nil {
return nil, fmt.Errorf("run mysqldump: %w: %s", runErr, stderrWriter.collected())
}
info, err := file.Stat()
if err != nil {
@@ -109,9 +110,10 @@ func mysqlEnv(password string) []string {
// logLineWriter streams each line of output to a LogWriter in real-time.
type logLineWriter struct {
writer LogWriter
prefix string
buf bytes.Buffer
writer LogWriter
prefix string
pending []byte
output []byte
}
func newLogLineWriter(w LogWriter, prefix string) *logLineWriter {
@@ -119,28 +121,43 @@ func newLogLineWriter(w LogWriter, prefix string) *logLineWriter {
}
func (w *logLineWriter) Write(p []byte) (int, error) {
n := len(p)
w.buf.Write(p)
scanner := bufio.NewScanner(strings.NewReader(w.buf.String()))
var remaining string
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line != "" {
w.writer.WriteLine(fmt.Sprintf("[%s] %s", w.prefix, line))
w.output = append(w.output, p...)
w.pending = append(w.pending, p...)
consumed := 0
for {
newline := bytes.IndexByte(w.pending[consumed:], '\n')
if newline < 0 {
break
}
end := consumed + newline
w.emit(w.pending[consumed:end])
consumed = end + 1
}
// Keep any partial last line (no newline yet)
lastNl := bytes.LastIndexByte(p, '\n')
if lastNl >= 0 {
remaining = w.buf.String()[w.buf.Len()-(len(p)-lastNl-1):]
w.buf.Reset()
w.buf.WriteString(remaining)
if consumed > 0 {
copy(w.pending, w.pending[consumed:])
w.pending = w.pending[:len(w.pending)-consumed]
}
return len(p), nil
}
// Flush emits the final unterminated line. It is safe to call more than once.
func (w *logLineWriter) Flush() {
if len(w.pending) == 0 {
return
}
w.emit(w.pending)
w.pending = w.pending[:0]
}
func (w *logLineWriter) emit(raw []byte) {
line := strings.TrimSpace(string(raw))
if line != "" {
w.writer.WriteLine(fmt.Sprintf("[%s] %s", w.prefix, line))
}
return n, nil
}
func (w *logLineWriter) collected() string {
return strings.TrimSpace(w.buf.String())
return strings.TrimSpace(string(w.output))
}
func formatFileSize(size int64) string {
-171
View File
@@ -1,171 +0,0 @@
//go:build ignore
package backup
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strings"
)
type PostgreSQLRunner struct {
executor CommandExecutor
}
func NewPostgreSQLRunner(executor CommandExecutor) *PostgreSQLRunner {
if executor == nil {
executor = NewOSCommandExecutor()
}
return &PostgreSQLRunner{executor: executor}
}
func (r *PostgreSQLRunner) Type() string {
return "postgresql"
}
func (r *PostgreSQLRunner) Run(ctx context.Context, spec TaskSpec, logger LogSink) (*Result, error) {
if _, err := r.executor.LookPath("pg_dump"); err != nil {
return nil, fmt.Errorf("pg_dump is required: %w", err)
}
databases := splitDatabaseNames(spec.DBName)
if len(databases) == 0 {
return nil, fmt.Errorf("postgresql database name is required")
}
tempDir, err := CreateTaskTempDir(spec.TaskName, spec.StartedAt)
if err != nil {
return nil, err
}
if len(databases) == 1 {
return r.dumpSingleDatabase(ctx, spec, databases[0], tempDir, logger)
}
multiDumpDir := filepath.Join(tempDir, "postgres-dumps")
if err := os.MkdirAll(multiDumpDir, 0o755); err != nil {
return nil, fmt.Errorf("create postgres multi dump directory: %w", err)
}
for _, databaseName := range databases {
if _, err := r.dumpDatabaseToFile(ctx, spec, databaseName, filepath.Join(multiDumpDir, sanitizeDumpName(databaseName)+".sql"), logger); err != nil {
return nil, err
}
}
fileName := BuildArtifactName(spec.TaskName, spec.StartedAt, "tar.gz")
artifactPath := filepath.Join(tempDir, fileName)
size, err := CreateTarGz(ctx, multiDumpDir, nil, artifactPath, logger)
if err != nil {
return nil, err
}
return &Result{ArtifactPath: artifactPath, FileName: fileName, Size: size, StorageKey: BuildStorageKey("postgresql", spec.StartedAt, fileName)}, nil
}
func (r *PostgreSQLRunner) Restore(ctx context.Context, spec TaskSpec, artifactPath string, logger LogSink) error {
if _, err := r.executor.LookPath("psql"); err != nil {
return fmt.Errorf("psql is required: %w", err)
}
databases := splitDatabaseNames(spec.DBName)
if len(databases) == 0 {
return fmt.Errorf("postgresql database name is required")
}
if strings.HasSuffix(strings.ToLower(artifactPath), ".tar.gz") {
restoreDir, err := CreateTaskTempDir(spec.TaskName+"-restore", spec.StartedAt)
if err != nil {
return err
}
if err := ExtractTarGz(ctx, artifactPath, restoreDir, logger); err != nil {
return err
}
for _, databaseName := range databases {
filePath := filepath.Join(restoreDir, filepath.Base(restoreDir), sanitizeDumpName(databaseName)+".sql")
if _, err := os.Stat(filePath); err != nil {
fallback := filepath.Join(restoreDir, "postgres-dumps", sanitizeDumpName(databaseName)+".sql")
filePath = fallback
}
if err := r.restoreDatabaseFromFile(ctx, spec, databaseName, filePath, logger); err != nil {
return err
}
}
return nil
}
return r.restoreDatabaseFromFile(ctx, spec, databases[0], artifactPath, logger)
}
func (r *PostgreSQLRunner) dumpSingleDatabase(ctx context.Context, spec TaskSpec, databaseName string, tempDir string, logger LogSink) (*Result, error) {
fileName := BuildArtifactName(spec.TaskName, spec.StartedAt, "sql")
artifactPath := filepath.Join(tempDir, fileName)
size, err := r.dumpDatabaseToFile(ctx, spec, databaseName, artifactPath, logger)
if err != nil {
return nil, err
}
return &Result{ArtifactPath: artifactPath, FileName: fileName, Size: size, StorageKey: BuildStorageKey("postgresql", spec.StartedAt, fileName)}, nil
}
func (r *PostgreSQLRunner) dumpDatabaseToFile(ctx context.Context, spec TaskSpec, databaseName string, artifactPath string, logger LogSink) (int64, error) {
output, err := os.Create(filepath.Clean(artifactPath))
if err != nil {
return 0, fmt.Errorf("create postgres dump file: %w", err)
}
defer output.Close()
stderr := &bytes.Buffer{}
args := []string{"-h", spec.DBHost, "-p", fmt.Sprintf("%d", spec.DBPort), "-U", spec.DBUser, "-d", databaseName, "--no-owner", "--no-privileges"}
if logger != nil {
logger.Infof("开始执行 pg_dump%s", databaseName)
}
if err := r.executor.Run(ctx, "pg_dump", args, postgresEnv(spec.DBPassword), nil, output, stderr); err != nil {
return 0, fmt.Errorf("run pg_dump: %w: %s", err, strings.TrimSpace(stderr.String()))
}
info, err := output.Stat()
if err != nil {
return 0, fmt.Errorf("stat postgres dump file: %w", err)
}
return info.Size(), nil
}
func (r *PostgreSQLRunner) restoreDatabaseFromFile(ctx context.Context, spec TaskSpec, databaseName string, artifactPath string, logger LogSink) error {
input, err := os.Open(filepath.Clean(artifactPath))
if err != nil {
return fmt.Errorf("open postgres restore file: %w", err)
}
defer input.Close()
stderr := &bytes.Buffer{}
args := []string{"-h", spec.DBHost, "-p", fmt.Sprintf("%d", spec.DBPort), "-U", spec.DBUser, "-d", databaseName}
if logger != nil {
logger.Infof("开始执行 psql 恢复:%s", databaseName)
}
if err := r.executor.Run(ctx, "psql", args, postgresEnv(spec.DBPassword), input, nil, stderr); err != nil {
return fmt.Errorf("run psql restore: %w: %s", err, strings.TrimSpace(stderr.String()))
}
return nil
}
func postgresEnv(password string) map[string]string {
if strings.TrimSpace(password) == "" {
return nil
}
return map[string]string{"PGPASSWORD": password}
}
func splitDatabaseNames(value string) []string {
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
continue
}
result = append(result, trimmed)
}
return result
}
func sanitizeDumpName(value string) string {
trimmed := strings.TrimSpace(strings.ToLower(value))
trimmed = strings.ReplaceAll(trimmed, " ", "-")
trimmed = strings.ReplaceAll(trimmed, "/", "-")
trimmed = strings.ReplaceAll(trimmed, "\\", "-")
trimmed = strings.Trim(trimmed, "-._")
if trimmed == "" {
return "database"
}
return trimmed
}
+5 -3
View File
@@ -43,12 +43,14 @@ func (r *PostgreSQLRunner) Run(ctx context.Context, task TaskSpec, writer LogWri
}
writer.WriteLine(fmt.Sprintf("连接到 PostgreSQL: %s:%d", task.Database.Host, task.Database.Port))
writer.WriteLine(fmt.Sprintf("备份数据库: %s", strings.Join(dbNames, ", ")))
stderrWriter := newLogLineWriter(writer, "pg_dump")
for index, name := range dbNames {
args := []string{"--clean", "--if-exists", "--create", "--format=plain", "-h", task.Database.Host, "-p", strconv.Itoa(task.Database.Port), "-U", task.Database.User, "--dbname", name}
writer.WriteLine(fmt.Sprintf("开始导出数据库 [%d/%d]: %s", index+1, len(dbNames), name))
if err := r.executor.Run(ctx, "pg_dump", args, CommandOptions{Stdout: file, Stderr: stderrWriter, Env: append(os.Environ(), "PGPASSWORD="+task.Database.Password)}); err != nil {
return nil, fmt.Errorf("run pg_dump for %s: %w", name, err)
stderrWriter := newLogLineWriter(writer, "pg_dump")
runErr := r.executor.Run(ctx, "pg_dump", args, CommandOptions{Stdout: file, Stderr: stderrWriter, Env: append(os.Environ(), "PGPASSWORD="+task.Database.Password)})
stderrWriter.Flush()
if runErr != nil {
return nil, fmt.Errorf("run pg_dump for %s: %w", name, runErr)
}
writer.WriteLine(fmt.Sprintf("数据库 %s 导出完成", name))
if index < len(dbNames)-1 {
+12
View File
@@ -20,6 +20,18 @@ func NewRegistry(runners ...BackupRunner) *Registry {
return registry
}
// NewDefaultRegistry returns the runner set shared by Master and Agent.
func NewDefaultRegistry() *Registry {
return NewRegistry(
NewFileRunner(),
NewSQLiteRunner(),
NewMySQLRunner(nil),
NewPostgreSQLRunner(nil),
NewSAPHANARunner(nil),
NewMongoDBRunner(nil),
)
}
func (r *Registry) Register(runner BackupRunner) {
if runner == nil {
return
+1
View File
@@ -304,6 +304,7 @@ func (r *SAPHANARunner) runHdbsqlWithRetry(ctx context.Context, name string, arg
}
stderrWriter := newLogLineWriter(writer, "hdbsql")
err := r.executor.Run(ctx, name, args, CommandOptions{Stderr: stderrWriter})
stderrWriter.Flush()
if err == nil {
return nil
}