feat: docker compose backup

This commit is contained in:
2026-07-04 17:20:28 +08:00
commit 5c8944fd58
27 changed files with 2070 additions and 0 deletions
+140
View File
@@ -0,0 +1,140 @@
package backup
import (
"context"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
)
// DirectoryCopier copies a directory tree with optional exclusions.
type DirectoryCopier struct {
Exclude []string
Context context.Context // optional; checked during copy
}
// Copy recursively copies src to dst, skipping paths that match any
// exclusion pattern (filepath.Match semantics). Skips special files
// (sockets, FIFOs, devices) that cannot be meaningfully copied.
func (c *DirectoryCopier) Copy(src, dst string) error {
return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if c.Context != nil {
select {
case <-c.Context.Done():
return c.Context.Err()
default:
}
}
rel, err := filepath.Rel(src, path)
if err != nil {
return fmt.Errorf("rel(%s, %s): %w", src, path, err)
}
if rel == "." {
return nil
}
for _, pattern := range c.Exclude {
matched, err := filepath.Match(pattern, rel)
if err != nil {
continue
}
baseMatched, _ := filepath.Match(pattern, d.Name())
if matched || baseMatched {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return copyDirMetadata(path, target, d)
}
if isSpecial(d) {
return nil
}
return copyFile(path, target, d)
})
}
func copyDirMetadata(src, dst string, entry fs.DirEntry) error {
info, err := entry.Info()
if err != nil {
return err
}
if err := os.MkdirAll(dst, info.Mode()); err != nil {
return err
}
if err := os.Chmod(dst, info.Mode()); err != nil {
return err
}
return os.Chtimes(dst, info.ModTime(), info.ModTime())
}
// isSpecial returns true if the entry is a socket, FIFO, device, or other
// non-regular, non-symlink, non-directory file.
func isSpecial(d fs.DirEntry) bool {
if d.Type().IsRegular() || d.Type()&fs.ModeSymlink != 0 {
return false
}
info, err := d.Info()
if err != nil {
return true // can't stat → skip it
}
mode := info.Mode()
return mode&(os.ModeSocket|os.ModeNamedPipe|os.ModeDevice|os.ModeCharDevice) != 0
}
func copyFile(src, dst string, entry fs.DirEntry) error {
info, err := entry.Info()
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
link, err := os.Readlink(src)
if err != nil {
return err
}
_ = os.Remove(dst)
return os.Symlink(link, dst)
}
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode())
if err != nil {
return err
}
_, copyErr := io.Copy(dstFile, srcFile)
closeErr := dstFile.Close()
if copyErr != nil {
return copyErr
}
if closeErr != nil {
return closeErr
}
if err := os.Chmod(dst, info.Mode()); err != nil {
return err
}
return os.Chtimes(dst, info.ModTime(), info.ModTime())
}
+261
View File
@@ -0,0 +1,261 @@
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.
stopStart := time.Now()
if err := executor.Stop(ctx); err != nil {
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 {
return walkErr
}
return closeErr
}
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
}
+51
View File
@@ -0,0 +1,51 @@
package backup
import (
"context"
"fmt"
"os/exec"
)
// ComposeExecutor runs docker compose commands.
type ComposeExecutor struct {
ProjectPath string
ComposeFile string
}
// NewComposeExecutor creates an executor for a project.
func NewComposeExecutor(projectPath, composeFile string) *ComposeExecutor {
return &ComposeExecutor{
ProjectPath: projectPath,
ComposeFile: composeFile,
}
}
// Stop runs `docker compose -f <file> stop`.
func (e *ComposeExecutor) Stop(ctx context.Context) error {
args := []string{"compose", "-f", e.ComposeFile, "stop"}
cmd := exec.CommandContext(ctx, "docker", args...)
cmd.Dir = e.ProjectPath
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("docker compose stop: %w\n%s", err, string(out))
}
return nil
}
// Start runs `docker compose -f <file> up -d`.
func (e *ComposeExecutor) Start(ctx context.Context) error {
args := []string{"compose", "-f", e.ComposeFile, "up", "-d"}
cmd := exec.CommandContext(ctx, "docker", args...)
cmd.Dir = e.ProjectPath
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("docker compose up -d: %w\n%s", err, string(out))
}
return nil
}
// IsDockerAvailable checks whether docker is on PATH.
func IsDockerAvailable() bool {
_, err := exec.LookPath("docker")
return err == nil
}
+17
View File
@@ -0,0 +1,17 @@
package backup
import "fmt"
// FormatSize formats a byte count as a human-readable string.
func FormatSize(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for b := n / unit; b >= unit; b /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp])
}
+91
View File
@@ -0,0 +1,91 @@
package backup
import (
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// RetentionPolicy defines how many backups to keep.
type RetentionPolicy struct {
Count int // keep at most N most recent backups
Days int // also delete backups older than N days (0 = disabled)
}
// ApplyRetention removes old backups from the project's backup directory.
// Returns the number of files removed.
func ApplyRetention(backupDir, projectName string, policy RetentionPolicy) (int, error) {
dir := filepath.Join(backupDir, projectName)
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, err
}
type fileInfo struct {
name string
modTime time.Time
}
var backups []fileInfo
for _, entry := range entries {
if entry.IsDir() || !isBackupArchive(projectName, entry.Name()) {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
backups = append(backups, fileInfo{
name: entry.Name(),
modTime: info.ModTime(),
})
}
if len(backups) == 0 {
return 0, nil
}
// Sort newest-first by modification time.
sort.Slice(backups, func(i, j int) bool {
return backups[i].modTime.After(backups[j].modTime)
})
removed := 0
// Enforce count-based retention.
if policy.Count > 0 && len(backups) > policy.Count {
for _, b := range backups[policy.Count:] {
path := filepath.Join(dir, b.name)
if err := os.Remove(path); err == nil {
removed++
}
}
// Trim the list for the next check.
backups = backups[:policy.Count]
}
// Enforce age-based retention.
if policy.Days > 0 {
cutoff := time.Now().AddDate(0, 0, -policy.Days)
for _, b := range backups {
if b.modTime.Before(cutoff) {
path := filepath.Join(dir, b.name)
if err := os.Remove(path); err == nil {
removed++
}
}
}
}
return removed, nil
}
func isBackupArchive(projectName, name string) bool {
return strings.HasPrefix(name, projectName+"-") && strings.HasSuffix(name, ".tar.gz")
}
+164
View File
@@ -0,0 +1,164 @@
package backup
import (
"os"
"path/filepath"
"sort"
"testing"
"time"
)
func TestApplyRetentionByCount(t *testing.T) {
dir := t.TempDir()
projDir := filepath.Join(dir, "testapp")
os.MkdirAll(projDir, 0755)
for i := 0; i < 5; i++ {
name := "testapp-20250101-00000" + string(rune('1'+i)) + ".tar.gz"
f, err := os.Create(filepath.Join(projDir, name))
if err != nil {
t.Fatal(err)
}
f.Close()
mt := time.Now().Add(-time.Duration(5-i) * time.Hour)
os.Chtimes(f.Name(), mt, mt)
}
removed, err := ApplyRetention(dir, "testapp", RetentionPolicy{Count: 3})
if err != nil {
t.Fatal(err)
}
if removed != 2 {
t.Errorf("expected 2 removed, got %d", removed)
}
entries, _ := os.ReadDir(projDir)
if len(entries) != 3 {
t.Errorf("expected 3 remaining, got %d", len(entries))
}
}
func TestApplyRetentionByDays(t *testing.T) {
dir := t.TempDir()
projDir := filepath.Join(dir, "testapp")
os.MkdirAll(projDir, 0755)
oldTime := time.Now().AddDate(0, 0, -10)
recentTime := time.Now().Add(-1 * time.Hour)
f1, _ := os.Create(filepath.Join(projDir, "testapp-old.tar.gz"))
f1.Close()
os.Chtimes(f1.Name(), oldTime, oldTime)
f2, _ := os.Create(filepath.Join(projDir, "testapp-recent.tar.gz"))
f2.Close()
os.Chtimes(f2.Name(), recentTime, recentTime)
removed, err := ApplyRetention(dir, "testapp", RetentionPolicy{Count: 0, Days: 7})
if err != nil {
t.Fatal(err)
}
if removed != 1 {
t.Errorf("expected 1 removed, got %d", removed)
}
entries, _ := os.ReadDir(projDir)
if len(entries) != 1 {
t.Errorf("expected 1 remaining, got %d", len(entries))
}
}
func TestApplyRetentionIgnoresNonBackupFiles(t *testing.T) {
dir := t.TempDir()
projDir := filepath.Join(dir, "testapp")
os.MkdirAll(projDir, 0755)
keepNames := []string{"README.txt", "otherapp-20250101.tar.gz", "testapp-note.txt"}
for _, name := range keepNames {
f, err := os.Create(filepath.Join(projDir, name))
if err != nil {
t.Fatal(err)
}
f.Close()
}
for i := 0; i < 3; i++ {
name := "testapp-20250101-00000" + string(rune('1'+i)) + ".tar.gz"
f, err := os.Create(filepath.Join(projDir, name))
if err != nil {
t.Fatal(err)
}
f.Close()
mt := time.Now().Add(-time.Duration(3-i) * time.Hour)
os.Chtimes(f.Name(), mt, mt)
}
removed, err := ApplyRetention(dir, "testapp", RetentionPolicy{Count: 1})
if err != nil {
t.Fatal(err)
}
if removed != 2 {
t.Errorf("expected 2 removed, got %d", removed)
}
for _, name := range keepNames {
if _, err := os.Stat(filepath.Join(projDir, name)); err != nil {
t.Fatalf("non-backup file %s should remain: %v", name, err)
}
}
}
func TestApplyRetentionEmptyDir(t *testing.T) {
dir := t.TempDir()
removed, err := ApplyRetention(dir, "nonexistent", RetentionPolicy{Count: 7})
if err != nil {
t.Fatal(err)
}
if removed != 0 {
t.Errorf("expected 0 removed, got %d", removed)
}
}
func TestApplyRetentionSortOrder(t *testing.T) {
dir := t.TempDir()
projDir := filepath.Join(dir, "testapp")
os.MkdirAll(projDir, 0755)
times := []time.Time{
time.Now().Add(-5 * time.Hour),
time.Now().Add(-1 * time.Hour),
time.Now().Add(-3 * time.Hour),
}
for i, mt := range times {
name := "testapp-2025010" + string(rune('1'+i)) + "-000001.tar.gz"
f, _ := os.Create(filepath.Join(projDir, name))
f.Close()
os.Chtimes(f.Name(), mt, mt)
}
removed, err := ApplyRetention(dir, "testapp", RetentionPolicy{Count: 2})
if err != nil {
t.Fatal(err)
}
if removed != 1 {
t.Fatalf("expected 1 removed, got %d", removed)
}
entries, _ := os.ReadDir(projDir)
names := make([]string, len(entries))
for i, e := range entries {
names[i] = e.Name()
}
sort.Strings(names)
expected := []string{"testapp-20250102-000001.tar.gz", "testapp-20250103-000001.tar.gz"}
if len(names) != len(expected) {
t.Fatalf("expected %v, got %v", expected, names)
}
for i := range expected {
if expected[i] != names[i] {
t.Errorf("expected[%d]=%s, got %s", i, expected[i], names[i])
}
}
}
+143
View File
@@ -0,0 +1,143 @@
package config
import (
"fmt"
"os"
"strings"
)
// Config is the top-level configuration.
type Config struct {
Global GlobalConfig `mapstructure:"global"`
Projects []ProjectConfig `mapstructure:"projects"`
Remote *RemoteConfig `mapstructure:"remote"`
}
// GlobalConfig holds global settings.
type GlobalConfig struct {
BackupDir string `mapstructure:"backup_dir"`
TempDir string `mapstructure:"temp_dir"`
}
// ProjectConfig defines one Docker Compose project to back up.
type ProjectConfig struct {
Name string `mapstructure:"name"`
Path string `mapstructure:"path"`
ComposeFile string `mapstructure:"compose_file"`
Cron string `mapstructure:"cron"`
Exclude []string `mapstructure:"exclude"`
Retention RetentionConfig `mapstructure:"retention"`
}
// RetentionConfig controls backup cleanup policy.
type RetentionConfig struct {
Count int `mapstructure:"count"`
Days int `mapstructure:"days"`
}
// RemoteConfig holds optional remote upload targets.
type RemoteConfig struct {
S3 *S3Config `mapstructure:"s3"`
WebDAV *WebDAVConfig `mapstructure:"webdav"`
}
// S3Config is the S3-compatible storage backend config.
type S3Config struct {
Endpoint string `mapstructure:"endpoint"`
Bucket string `mapstructure:"bucket"`
AccessKey string `mapstructure:"access_key"`
SecretKey string `mapstructure:"secret_key"`
Region string `mapstructure:"region"`
PathPrefix string `mapstructure:"path_prefix"`
}
// WebDAVConfig is the WebDAV storage backend config.
type WebDAVConfig struct {
URL string `mapstructure:"url"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
}
// DefaultRetentionCount is used when no retention count is specified.
const DefaultRetentionCount = 7
// Validate checks the configuration and fills in defaults.
func (c *Config) Validate() error {
if c.Global.BackupDir == "" {
return fmt.Errorf("global.backup_dir is required")
}
if c.Global.TempDir == "" {
c.Global.TempDir = os.TempDir()
}
if len(c.Projects) == 0 {
return fmt.Errorf("at least one project must be configured")
}
for i := range c.Projects {
p := &c.Projects[i]
if p.Name == "" {
return fmt.Errorf("projects[%d]: name is required", i)
}
if p.Path == "" {
return fmt.Errorf("projects[%d] (%s): path is required", i, p.Name)
}
if p.ComposeFile == "" {
p.ComposeFile = "docker-compose.yml"
}
if p.Retention.Count <= 0 {
p.Retention.Count = DefaultRetentionCount
}
}
// Expand environment variable placeholders in remote configs and validate required fields.
if c.Remote != nil {
if c.Remote.S3 != nil {
c.Remote.S3.AccessKey = expandEnv(c.Remote.S3.AccessKey)
c.Remote.S3.SecretKey = expandEnv(c.Remote.S3.SecretKey)
if strings.TrimSpace(c.Remote.S3.Bucket) == "" {
return fmt.Errorf("remote.s3.bucket is required")
}
if strings.TrimSpace(c.Remote.S3.Region) == "" {
return fmt.Errorf("remote.s3.region is required")
}
if strings.TrimSpace(c.Remote.S3.AccessKey) == "" {
return fmt.Errorf("remote.s3.access_key is required")
}
if strings.TrimSpace(c.Remote.S3.SecretKey) == "" {
return fmt.Errorf("remote.s3.secret_key is required")
}
}
if c.Remote.WebDAV != nil {
c.Remote.WebDAV.Username = expandEnv(c.Remote.WebDAV.Username)
c.Remote.WebDAV.Password = expandEnv(c.Remote.WebDAV.Password)
if strings.TrimSpace(c.Remote.WebDAV.URL) == "" {
return fmt.Errorf("remote.webdav.url is required")
}
}
}
return nil
}
// expandEnv replaces ${VAR} or $VAR placeholders with environment values.
func expandEnv(s string) string {
return os.Expand(s, func(key string) string {
return os.Getenv(key)
})
}
// HasRemote returns true if at least one remote backend is configured.
func (c *Config) HasRemote() bool {
return c.Remote != nil && (c.Remote.S3 != nil || c.Remote.WebDAV != nil)
}
// HasCron returns true if at least one project has a cron expression.
func (c *Config) HasCron() bool {
for _, p := range c.Projects {
if strings.TrimSpace(p.Cron) != "" {
return true
}
}
return false
}
+119
View File
@@ -0,0 +1,119 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestLoadValid(t *testing.T) {
yaml := `
global:
backup_dir: /tmp/backups
projects:
- name: testapp
path: /opt/testapp
`
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Global.BackupDir != "/tmp/backups" {
t.Errorf("backup_dir = %q", cfg.Global.BackupDir)
}
if len(cfg.Projects) != 1 {
t.Fatalf("expected 1 project, got %d", len(cfg.Projects))
}
p := cfg.Projects[0]
if p.Name != "testapp" {
t.Errorf("name = %q", p.Name)
}
if p.ComposeFile != "docker-compose.yml" {
t.Errorf("compose_file default = %q", p.ComposeFile)
}
if p.Retention.Count != DefaultRetentionCount {
t.Errorf("retention count default = %d", p.Retention.Count)
}
if cfg.HasRemote() {
t.Error("HasRemote should be false")
}
}
func TestLoadMissingBackupDir(t *testing.T) {
yaml := `
projects:
- name: x
path: /x
`
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
os.WriteFile(path, []byte(yaml), 0644)
_, err := Load(path)
if err == nil {
t.Fatal("expected error for missing backup_dir")
}
}
func TestLoadNoProjects(t *testing.T) {
yaml := `
global:
backup_dir: /tmp
`
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
os.WriteFile(path, []byte(yaml), 0644)
_, err := Load(path)
if err == nil {
t.Fatal("expected error for no projects")
}
}
func TestHasCron(t *testing.T) {
cfg := &Config{
Projects: []ProjectConfig{
{Name: "a", Cron: ""},
{Name: "b", Cron: "0 3 * * *"},
},
}
if !cfg.HasCron() {
t.Error("HasCron should be true")
}
cfg2 := &Config{
Projects: []ProjectConfig{
{Name: "a", Cron: ""},
},
}
if cfg2.HasCron() {
t.Error("HasCron should be false")
}
}
func TestValidateWebDAVRequiresURL(t *testing.T) {
cfg := &Config{
Global: GlobalConfig{BackupDir: "/tmp/backups"},
Projects: []ProjectConfig{{Name: "app", Path: "/tmp/app"}},
Remote: &RemoteConfig{WebDAV: &WebDAVConfig{}},
}
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "remote.webdav.url is required") {
t.Fatalf("expected webdav url error, got %v", err)
}
}
func TestValidateS3RequiresFields(t *testing.T) {
cfg := &Config{
Global: GlobalConfig{BackupDir: "/tmp/backups"},
Projects: []ProjectConfig{{Name: "app", Path: "/tmp/app"}},
Remote: &RemoteConfig{S3: &S3Config{}},
}
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "remote.s3.bucket is required") {
t.Fatalf("expected s3 bucket error, got %v", err)
}
}
+35
View File
@@ -0,0 +1,35 @@
package config
import (
"fmt"
"strings"
"github.com/spf13/viper"
)
// Load reads and validates the configuration from a YAML file.
func Load(path string) (*Config, error) {
v := viper.New()
v.SetConfigFile(path)
v.SetConfigType("yaml")
// Allow env var overrides with DOCKER_BACKUP_ prefix.
v.SetEnvPrefix("DOCKER_BACKUP")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
if err := v.ReadInConfig(); err != nil {
return nil, fmt.Errorf("read config %s: %w", path, err)
}
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("unmarshal config: %w", err)
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("validate config: %w", err)
}
return &cfg, nil
}
+131
View File
@@ -0,0 +1,131 @@
package scheduler
import (
"context"
"fmt"
"log/slog"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/robfig/cron/v3"
"git.misaka.ren/M1saka/docker_backup/internal/backup"
"git.misaka.ren/M1saka/docker_backup/internal/config"
"git.misaka.ren/M1saka/docker_backup/internal/storage"
)
// Scheduler runs periodic backups using cron expressions.
type Scheduler struct {
cfg *config.Config
dryRun bool
logger *slog.Logger
locks map[string]*sync.Mutex
}
// New creates a new scheduler from the config.
func New(cfg *config.Config, dryRun bool) (*Scheduler, error) {
return &Scheduler{
cfg: cfg,
dryRun: dryRun,
logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})),
locks: make(map[string]*sync.Mutex),
}, nil
}
// Run starts the cron scheduler and blocks until a shutdown signal is received.
func (s *Scheduler) Run(ctx context.Context) error {
c := cron.New()
engine := backup.NewEngine(s.cfg.Global.BackupDir, s.cfg.Global.TempDir, s.dryRun, s.logger)
for _, proj := range s.cfg.Projects {
if proj.Cron == "" {
continue
}
proj := proj // capture for closure
if _, ok := s.locks[proj.Name]; !ok {
s.locks[proj.Name] = &sync.Mutex{}
}
projectLock := s.locks[proj.Name]
_, err := c.AddFunc(proj.Cron, func() {
if !projectLock.TryLock() {
s.logger.Warn("scheduled backup skipped because previous run is still active", "project", proj.Name)
return
}
defer projectLock.Unlock()
// Each cron job uses its own context with a generous timeout.
jobCtx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
defer cancel()
s.logger.Info("scheduled backup starting", "project", proj.Name)
results := engine.Run(jobCtx, []config.ProjectConfig{proj})
for _, r := range results {
if r.Error != nil {
s.logger.Error("scheduled backup failed", "project", r.ProjectName, "error", r.Error)
continue
}
s.logger.Info("scheduled backup done", "project", r.ProjectName, "file", r.BackupPath, "size", r.FileSize)
if s.dryRun {
s.logger.Info("dry-run: skipping retention cleanup and remote upload", "project", proj.Name)
continue
}
// Apply retention.
policy := backup.RetentionPolicy{
Count: proj.Retention.Count,
Days: proj.Retention.Days,
}
if n, err := backup.ApplyRetention(s.cfg.Global.BackupDir, proj.Name, policy); err != nil {
s.logger.Warn("retention cleanup failed", "project", proj.Name, "error", err)
} else if n > 0 {
s.logger.Info("retention cleaned", "project", proj.Name, "removed", n)
}
// Upload to remote if configured.
if s.cfg.HasRemote() {
backends, err := storage.FromConfig(s.cfg.Remote)
if err != nil {
s.logger.Warn("remote init failed", "error", err)
continue
}
for _, b := range backends {
if err := b.Upload(jobCtx, proj.Name, r.BackupPath); err != nil {
s.logger.Warn("remote upload failed", "project", proj.Name, "error", err)
} else {
s.logger.Info("remote upload done", "project", proj.Name)
}
}
}
}
})
if err != nil {
return fmt.Errorf("add cron for %s (%s): %w", proj.Name, proj.Cron, err)
}
s.logger.Info("scheduled", "project", proj.Name, "cron", proj.Cron)
}
c.Start()
// Wait for shutdown signal.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
select {
case sig := <-sigCh:
s.logger.Info("shutting down", "signal", sig)
case <-ctx.Done():
s.logger.Info("shutting down", "reason", ctx.Err())
}
// Gracefully stop cron (wait for running jobs).
stopCtx := c.Stop()
<-stopCtx.Done()
s.logger.Info("daemon stopped")
return nil
}
+61
View File
@@ -0,0 +1,61 @@
package scheduler
import (
"context"
"strings"
"testing"
"git.misaka.ren/M1saka/docker_backup/internal/config"
)
func TestNew(t *testing.T) {
cfg := &config.Config{
Global: config.GlobalConfig{BackupDir: "/tmp/backups"},
Projects: []config.ProjectConfig{
{Name: "test", Path: "/tmp", Cron: "0 3 * * *"},
},
}
s, err := New(cfg, false)
if err != nil {
t.Fatal(err)
}
if s == nil {
t.Fatal("scheduler is nil")
}
}
func TestRunInvalidCronReturnsError(t *testing.T) {
cfg := &config.Config{
Global: config.GlobalConfig{BackupDir: "/tmp/backups"},
Projects: []config.ProjectConfig{
{Name: "test", Path: "/tmp", Cron: "this is not a cron expression"},
},
}
s, err := New(cfg, false)
if err != nil {
t.Fatal(err)
}
err = s.Run(context.Background())
if err == nil {
t.Fatal("expected invalid cron error")
}
if !strings.Contains(err.Error(), "add cron for test") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestNewNoCronProjects(t *testing.T) {
cfg := &config.Config{
Global: config.GlobalConfig{BackupDir: "/tmp/backups"},
Projects: []config.ProjectConfig{
{Name: "test", Path: "/tmp"},
},
}
s, err := New(cfg, false)
if err != nil {
t.Fatal(err)
}
if s == nil {
t.Fatal("scheduler is nil")
}
}
+78
View File
@@ -0,0 +1,78 @@
package storage
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
dcfg "git.misaka.ren/M1saka/docker_backup/internal/config"
)
// S3Backend uploads to S3-compatible storage.
type S3Backend struct {
cfg *dcfg.S3Config
client *s3.Client
}
// NewS3Backend creates a configured S3 backend.
func NewS3Backend(cfg *dcfg.S3Config) (*S3Backend, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
awsCfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion(cfg.Region),
config.WithCredentialsProvider(
credentials.NewStaticCredentialsProvider(cfg.AccessKey, cfg.SecretKey, ""),
),
)
if err != nil {
return nil, fmt.Errorf("load aws config: %w", err)
}
s3Opts := func(o *s3.Options) {
if cfg.Endpoint != "" {
o.BaseEndpoint = aws.String(cfg.Endpoint)
o.UsePathStyle = true
}
}
return &S3Backend{
cfg: cfg,
client: s3.NewFromConfig(awsCfg, s3Opts),
}, nil
}
// Upload implements Backend.
func (s *S3Backend) Upload(ctx context.Context, projectName, localPath string) error {
file, err := os.Open(localPath)
if err != nil {
return fmt.Errorf("open: %w", err)
}
defer file.Close()
key := filepath.Base(localPath)
if s.cfg.PathPrefix != "" {
key = s.cfg.PathPrefix + projectName + "/" + key
} else {
key = projectName + "/" + key
}
_, err = s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.cfg.Bucket),
Key: aws.String(key),
Body: file,
ContentType: aws.String("application/gzip"),
})
if err != nil {
return fmt.Errorf("s3 put object: %w", err)
}
return nil
}
+32
View File
@@ -0,0 +1,32 @@
package storage
import (
"context"
"fmt"
"git.misaka.ren/M1saka/docker_backup/internal/config"
)
// Backend is the interface for uploading backups to remote storage.
type Backend interface {
Upload(ctx context.Context, projectName, localPath string) error
}
// FromConfig creates storage backends from the config.
// Returns an error only if a backend is configured but fails to initialize.
func FromConfig(cfg *config.RemoteConfig) ([]Backend, error) {
var backends []Backend
if cfg.S3 != nil {
s3, err := NewS3Backend(cfg.S3)
if err != nil {
return nil, fmt.Errorf("s3: %w", err)
}
backends = append(backends, s3)
}
if cfg.WebDAV != nil {
backends = append(backends, NewWebDAVBackend(cfg.WebDAV))
}
return backends, nil
}
+101
View File
@@ -0,0 +1,101 @@
package storage
import (
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
dcfg "git.misaka.ren/M1saka/docker_backup/internal/config"
)
// WebDAVBackend uploads to a WebDAV server.
type WebDAVBackend struct {
cfg *dcfg.WebDAVConfig
client *http.Client
}
// NewWebDAVBackend creates a configured WebDAV backend.
func NewWebDAVBackend(cfg *dcfg.WebDAVConfig) *WebDAVBackend {
return &WebDAVBackend{
cfg: cfg,
client: &http.Client{
Timeout: 30 * time.Minute,
},
}
}
// Upload implements Backend.
func (w *WebDAVBackend) Upload(ctx context.Context, projectName, localPath string) error {
baseURL := strings.TrimRight(strings.TrimSpace(w.cfg.URL), "/")
if baseURL == "" {
return fmt.Errorf("webdav url is required")
}
file, err := os.Open(localPath)
if err != nil {
return fmt.Errorf("open: %w", err)
}
defer file.Close()
stat, err := file.Stat()
if err != nil {
return fmt.Errorf("stat: %w", err)
}
parentURL := baseURL + "/" + projectName
if err := w.mkcol(ctx, parentURL); err != nil {
return fmt.Errorf("webdav mkcol %s: %w", parentURL, err)
}
remoteURL := parentURL + "/" + filepath.Base(localPath)
req, err := http.NewRequestWithContext(ctx, http.MethodPut, remoteURL, file)
if err != nil {
return fmt.Errorf("new request: %w", err)
}
req.ContentLength = stat.Size()
req.Header.Set("Content-Type", "application/gzip")
if w.cfg.Username != "" || w.cfg.Password != "" {
req.SetBasicAuth(w.cfg.Username, w.cfg.Password)
}
resp, err := w.client.Do(req)
if err != nil {
return fmt.Errorf("webdav put: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("webdav put failed: %s (%d): %s", remoteURL, resp.StatusCode, string(body))
}
return nil
}
// mkcol creates a WebDAV collection (directory). Existing collections are OK.
func (w *WebDAVBackend) mkcol(ctx context.Context, url string) error {
req, err := http.NewRequestWithContext(ctx, "MKCOL", url, nil)
if err != nil {
return err
}
if w.cfg.Username != "" || w.cfg.Password != "" {
req.SetBasicAuth(w.cfg.Username, w.cfg.Password)
}
resp, err := w.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// 201 Created is success. Many servers return 405 if collection already exists.
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusMethodNotAllowed {
return nil
}
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
}