- go.mod: 将非法版本号 go 1.26.4 改为 go 1.26 - engine: 压缩失败时删除残留的半截 tar.gz 文件 - config: 支持仅按天数保留(count 和 days 均未设时才用默认 count=7) - engine: docker compose stop 失败时也尝试重启,避免容器停摆 - list: 仅显示真正的备份归档文件(新增导出 IsBackupArchive) - systemd: 移除 PrivateTmp,暂存目录改到安装目录内,避免私有 /tmp 被大目录撑爆
269 lines
6.7 KiB
Go
269 lines
6.7 KiB
Go
package backup
|
|
|
|
import (
|
|
"archive/tar"
|
|
"compress/gzip"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"git.misaka.ren/M1saka/docker_backup/internal/config"
|
|
)
|
|
|
|
// Result holds the outcome of backing up a single project.
|
|
type Result struct {
|
|
ProjectName string
|
|
Error error
|
|
BackupPath string
|
|
FileSize int64
|
|
Downtime time.Duration
|
|
Duration time.Duration
|
|
}
|
|
|
|
// Engine orchestrates backup of Docker Compose projects.
|
|
type Engine struct {
|
|
BackupDir string
|
|
TempDir string
|
|
DryRun bool
|
|
Logger *slog.Logger
|
|
}
|
|
|
|
// NewEngine creates a new backup engine.
|
|
func NewEngine(backupDir, tempDir string, dryRun bool, logger *slog.Logger) *Engine {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &Engine{
|
|
BackupDir: backupDir,
|
|
TempDir: tempDir,
|
|
DryRun: dryRun,
|
|
Logger: logger,
|
|
}
|
|
}
|
|
|
|
// Run backs up all configured projects. Results are returned in order.
|
|
func (e *Engine) Run(ctx context.Context, projects []config.ProjectConfig) []Result {
|
|
results := make([]Result, 0, len(projects))
|
|
for _, proj := range projects {
|
|
results = append(results, e.backupOne(ctx, proj))
|
|
}
|
|
return results
|
|
}
|
|
|
|
func (e *Engine) backupOne(ctx context.Context, proj config.ProjectConfig) Result {
|
|
start := time.Now()
|
|
result := Result{ProjectName: proj.Name}
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
result.Error = err
|
|
result.Duration = time.Since(start)
|
|
return result
|
|
}
|
|
|
|
e.Logger.Info("starting backup", "project", proj.Name, "path", proj.Path)
|
|
|
|
composeFilePath := filepath.Join(proj.Path, proj.ComposeFile)
|
|
if _, err := os.Stat(composeFilePath); err != nil && !e.DryRun {
|
|
result.Error = fmt.Errorf("compose file not found: %s: %w", composeFilePath, err)
|
|
result.Duration = time.Since(start)
|
|
return result
|
|
}
|
|
|
|
backupPath := e.backupPath(proj.Name)
|
|
if e.DryRun {
|
|
result.BackupPath = backupPath
|
|
result.Duration = time.Since(start)
|
|
e.Logger.Info("dry-run: backup plan complete", "project", proj.Name, "file", backupPath)
|
|
return result
|
|
}
|
|
|
|
executor := NewComposeExecutor(proj.Path, proj.ComposeFile)
|
|
|
|
// 1. Stop the compose project. If the stop is interrupted midway
|
|
// (e.g. the context is cancelled), containers may be left stopped, so
|
|
// always attempt a restart before returning.
|
|
stopStart := time.Now()
|
|
if err := executor.Stop(ctx); err != nil {
|
|
_ = restartCompose(executor)
|
|
result.Error = fmt.Errorf("stop: %w", err)
|
|
result.Duration = time.Since(start)
|
|
return result
|
|
}
|
|
e.Logger.Info("compose stopped", "project", proj.Name)
|
|
|
|
// 2. Create a staging directory.
|
|
stagingDir, err := os.MkdirTemp(e.TempDir, "dc-backup-"+proj.Name+"-*")
|
|
if err != nil {
|
|
_ = restartCompose(executor)
|
|
result.Error = fmt.Errorf("create staging dir: %w", err)
|
|
result.Duration = time.Since(start)
|
|
return result
|
|
}
|
|
|
|
// 3. Copy project directory to staging.
|
|
copier := &DirectoryCopier{Exclude: proj.Exclude, Context: ctx}
|
|
if err := copier.Copy(proj.Path, stagingDir); err != nil {
|
|
_ = os.RemoveAll(stagingDir)
|
|
_ = restartCompose(executor)
|
|
result.Error = fmt.Errorf("copy: %w", err)
|
|
result.Duration = time.Since(start)
|
|
return result
|
|
}
|
|
e.Logger.Info("files copied to staging", "project", proj.Name, "staging", stagingDir)
|
|
|
|
// 4. Immediately restart the compose project.
|
|
if err := restartCompose(executor); err != nil {
|
|
_ = os.RemoveAll(stagingDir)
|
|
result.Error = fmt.Errorf("start: %w", err)
|
|
result.Duration = time.Since(start)
|
|
return result
|
|
}
|
|
result.Downtime = time.Since(stopStart)
|
|
e.Logger.Info("compose restarted", "project", proj.Name, "downtime", result.Downtime)
|
|
|
|
// 5. Compress staging directory to the backup destination.
|
|
backupDir := filepath.Dir(backupPath)
|
|
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
|
_ = os.RemoveAll(stagingDir)
|
|
result.Error = fmt.Errorf("create backup dir: %w", err)
|
|
result.Duration = time.Since(start)
|
|
return result
|
|
}
|
|
|
|
if err := createTarGz(ctx, stagingDir, backupPath); err != nil {
|
|
_ = os.RemoveAll(stagingDir)
|
|
result.Error = fmt.Errorf("compress: %w", err)
|
|
result.Duration = time.Since(start)
|
|
return result
|
|
}
|
|
e.Logger.Info("backup compressed", "project", proj.Name, "file", backupPath)
|
|
|
|
// 6. Clean up staging.
|
|
_ = os.RemoveAll(stagingDir)
|
|
|
|
// 7. Record file size.
|
|
if fi, err := os.Stat(backupPath); err == nil {
|
|
result.FileSize = fi.Size()
|
|
}
|
|
|
|
result.BackupPath = backupPath
|
|
result.Duration = time.Since(start)
|
|
e.Logger.Info("backup complete", "project", proj.Name, "duration", result.Duration, "size", result.FileSize)
|
|
return result
|
|
}
|
|
|
|
func (e *Engine) backupPath(projectName string) string {
|
|
timestamp := time.Now().Format("20060102-150405.000000000")
|
|
backupFile := fmt.Sprintf("%s-%s.tar.gz", projectName, timestamp)
|
|
return filepath.Join(e.BackupDir, projectName, backupFile)
|
|
}
|
|
|
|
func restartCompose(executor *ComposeExecutor) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
return executor.Start(ctx)
|
|
}
|
|
|
|
// createTarGz creates a tar.gz archive from a source directory.
|
|
func createTarGz(ctx context.Context, srcDir, dstPath string) error {
|
|
f, err := os.Create(dstPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
gw := gzip.NewWriter(f)
|
|
tw := tar.NewWriter(gw)
|
|
|
|
walkErr := filepath.WalkDir(srcDir, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
rel, err := filepath.Rel(srcDir, path)
|
|
if err != nil {
|
|
return fmt.Errorf("rel: %w", err)
|
|
}
|
|
if rel == "." {
|
|
return nil
|
|
}
|
|
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
linkTarget := ""
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
linkTarget, err = os.Readlink(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
header, err := tar.FileInfoHeader(info, linkTarget)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
header.Name = filepath.ToSlash(rel)
|
|
|
|
if err := tw.WriteHeader(header); err != nil {
|
|
return err
|
|
}
|
|
|
|
if d.IsDir() || !info.Mode().IsRegular() {
|
|
return nil
|
|
}
|
|
|
|
return copyFileToTar(tw, path)
|
|
})
|
|
|
|
closeErr := closeTarGz(tw, gw, f)
|
|
if walkErr != nil {
|
|
_ = os.Remove(dstPath)
|
|
return walkErr
|
|
}
|
|
if closeErr != nil {
|
|
_ = os.Remove(dstPath)
|
|
return closeErr
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func closeTarGz(tw *tar.Writer, gw *gzip.Writer, f *os.File) error {
|
|
if err := tw.Close(); err != nil {
|
|
_ = gw.Close()
|
|
_ = f.Close()
|
|
return fmt.Errorf("close tar: %w", err)
|
|
}
|
|
if err := gw.Close(); err != nil {
|
|
_ = f.Close()
|
|
return fmt.Errorf("close gzip: %w", err)
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
return fmt.Errorf("close file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// copyFileToTar opens a file and copies it into the tar writer.
|
|
// The file is closed before returning — no deferred leak in a loop.
|
|
func copyFileToTar(tw *tar.Writer, path string) error {
|
|
srcFile, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer srcFile.Close()
|
|
|
|
_, err = io.Copy(tw, srcFile)
|
|
return err
|
|
} |