feat: docker compose backup
This commit is contained in:
@@ -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("──────────────────────────────────────────────")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user