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
+107
View File
@@ -0,0 +1,107 @@
package backup
import (
"fmt"
"log/slog"
"os"
"github.com/spf13/cobra"
"git.misaka.ren/M1saka/docker_backup/internal/backup"
"git.misaka.ren/M1saka/docker_backup/internal/config"
)
var backupCmd = &cobra.Command{
Use: "backup",
Short: "Run a backup of all configured projects",
Long: `Stop each Docker Compose project, copy its directory, restart immediately, and compress the snapshot.`,
RunE: runBackup,
}
func init() {
rootCmd.AddCommand(backupCmd)
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "config.yaml", "path to config file")
rootCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "print what would be done without executing")
}
func runBackup(cmd *cobra.Command, args []string) error {
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
cfg, err := config.Load(cfgFile)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
if !backup.IsDockerAvailable() {
logger.Warn("docker not found on PATH; commands will fail")
}
engine := backup.NewEngine(cfg.Global.BackupDir, cfg.Global.TempDir, dryRun, logger)
ctx := cmd.Context()
results := engine.Run(ctx, cfg.Projects)
// Print summary.
fmt.Println()
printResults(results)
// Apply retention and upload after backup.
for i, r := range results {
if r.Error != nil {
logger.Warn("skipping retention/upload for failed project", "project", r.ProjectName)
continue
}
proj := cfg.Projects[i]
// Retention cleanup.
if !dryRun {
policy := backup.RetentionPolicy{
Count: proj.Retention.Count,
Days: proj.Retention.Days,
}
cleaned, err := backup.ApplyRetention(cfg.Global.BackupDir, proj.Name, policy)
if err != nil {
logger.Warn("retention cleanup failed", "project", proj.Name, "error", err)
} else if cleaned > 0 {
logger.Info("retention cleanup", "project", proj.Name, "removed", cleaned)
}
}
// Remote upload.
if cfg.HasRemote() && !dryRun {
if err := uploadToRemotes(ctx, cfg.Remote, proj.Name, r.BackupPath, logger); err != nil {
logger.Warn("remote upload failed", "project", proj.Name, "error", err)
}
}
}
// Return non-zero exit if any project failed.
for _, r := range results {
if r.Error != nil {
return fmt.Errorf("one or more projects failed")
}
}
return nil
}
func printResults(results []backup.Result) {
fmt.Println("──────────────────────────────────────────────")
fmt.Println(" Backup Results")
fmt.Println("──────────────────────────────────────────────")
for _, r := range results {
status := "OK"
if r.Error != nil {
status = fmt.Sprintf("FAIL: %v", r.Error)
}
fmt.Printf(" %-12s %s\n", r.ProjectName, status)
if r.BackupPath != "" {
fmt.Printf(" file: %s\n", r.BackupPath)
fmt.Printf(" size: %s\n", backup.FormatSize(r.FileSize))
}
if r.Downtime > 0 {
fmt.Printf(" downtime: %v\n", r.Downtime.Round(0))
}
fmt.Printf(" duration: %v\n", r.Duration.Round(0))
fmt.Println()
}
fmt.Println("──────────────────────────────────────────────")
}
+39
View File
@@ -0,0 +1,39 @@
package backup
import (
"fmt"
"github.com/spf13/cobra"
"git.misaka.ren/M1saka/docker_backup/internal/config"
"git.misaka.ren/M1saka/docker_backup/internal/scheduler"
)
var daemonCmd = &cobra.Command{
Use: "daemon",
Short: "Run as a background daemon with cron scheduling",
Long: `Start the backup daemon that runs scheduled backups according to each project's cron expression.`,
RunE: runDaemon,
}
func init() {
rootCmd.AddCommand(daemonCmd)
}
func runDaemon(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(cfgFile)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
if !cfg.HasCron() {
return fmt.Errorf("no projects have cron expressions configured - cannot start daemon")
}
sched, err := scheduler.New(cfg, dryRun)
if err != nil {
return fmt.Errorf("create scheduler: %w", err)
}
fmt.Println("Starting backup daemon...")
return sched.Run(cmd.Context())
}
+83
View File
@@ -0,0 +1,83 @@
package backup
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/spf13/cobra"
"git.misaka.ren/M1saka/docker_backup/internal/backup"
"git.misaka.ren/M1saka/docker_backup/internal/config"
)
var listCmd = &cobra.Command{
Use: "list",
Short: "List configured projects and their backups",
RunE: runList,
}
func init() {
rootCmd.AddCommand(listCmd)
}
func runList(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(cfgFile)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
fmt.Printf("Config: %s\n\n", cfgFile)
for _, proj := range cfg.Projects {
fmt.Printf(" [%s]\n", proj.Name)
fmt.Printf(" path: %s\n", proj.Path)
fmt.Printf(" compose_file: %s\n", proj.ComposeFile)
if proj.Cron != "" {
fmt.Printf(" cron: %s\n", proj.Cron)
}
if len(proj.Exclude) > 0 {
fmt.Printf(" exclude: %s\n", strings.Join(proj.Exclude, ", "))
}
fmt.Printf(" retention: %d copies", proj.Retention.Count)
if proj.Retention.Days > 0 {
fmt.Printf(", %d days", proj.Retention.Days)
}
fmt.Println()
// List existing backups.
backupDir := filepath.Join(cfg.Global.BackupDir, proj.Name)
entries, err := os.ReadDir(backupDir)
if err != nil {
fmt.Printf(" backups: none (dir not found)\n\n")
continue
}
var backups []string
for _, e := range entries {
if !e.IsDir() {
backups = append(backups, e.Name())
}
}
sort.Sort(sort.Reverse(sort.StringSlice(backups)))
if len(backups) == 0 {
fmt.Printf(" backups: none\n\n")
continue
}
fmt.Printf(" backups:\n")
for _, b := range backups {
info, _ := os.Stat(filepath.Join(backupDir, b))
size := ""
if info != nil {
size = backup.FormatSize(info.Size())
}
fmt.Printf(" %s %s\n", b, size)
}
fmt.Println()
}
return nil
}
+14
View File
@@ -0,0 +1,14 @@
package backup
import (
"fmt"
"os"
)
// Execute runs the root command.
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+33
View File
@@ -0,0 +1,33 @@
package backup
import (
"context"
"log/slog"
"git.misaka.ren/M1saka/docker_backup/internal/config"
"git.misaka.ren/M1saka/docker_backup/internal/storage"
)
// uploadToRemotes uploads a backup file to all configured remote backends.
func uploadToRemotes(ctx context.Context, remote *config.RemoteConfig, projectName, localPath string, logger *slog.Logger) error {
backends, err := storage.FromConfig(remote)
if err != nil {
return err
}
if len(backends) == 0 {
return nil
}
var lastErr error
for _, b := range backends {
logger.Info("uploading to remote", "project", projectName, "file", localPath)
if err := b.Upload(ctx, projectName, localPath); err != nil {
logger.Warn("remote upload failed", "project", projectName, "error", err)
lastErr = err
continue
}
logger.Info("remote upload complete", "project", projectName)
}
return lastErr
}
+20
View File
@@ -0,0 +1,20 @@
package backup
import (
"github.com/spf13/cobra"
)
var (
cfgFile string
dryRun bool
)
// Version is set at build time via -ldflags.
var Version = "dev"
var rootCmd = &cobra.Command{
Use: "docker-compose-backup",
Short: "Backup Docker Compose projects with minimal downtime",
Long: `docker-compose-backup stops Docker Compose projects, copies their directories, restarts them immediately, and then asynchronously compresses the backups.`,
Version: Version,
}