commit 5c8944fd58387996dc89c7432281c604c9fa0d5e Author: m1saka Date: Sat Jul 4 17:20:28 2026 +0800 feat: docker compose backup diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cbdcb21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Build output +/docker-compose-backup +/docker-compose-backup.exe +/docker-compose-backup-* + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c4a4b92 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# docker-compose-backup + +A backup tool for Docker Compose projects with **minimal downtime** — stop, copy, restart (seconds), then compress after the service is back online. + +## How It Works + +1. `docker compose stop` — stops the containers +2. `copy` — copies the entire project directory to a staging area +3. `docker compose up -d` — restarts containers immediately (downtime ends here) +4. `tar.gz` — compresses the snapshot into the backup directory after restart +5. Optional: upload to S3 / WebDAV +6. Retention: auto-deletes old backups per policy + +## Installation + +```bash +go build -o docker-compose-backup . +# or cross-compile: +GOOS=linux GOARCH=arm64 go build -o docker-compose-backup-linux-arm64 . +``` + +## Quick Start + +1. Copy and edit the config: + +```bash +cp config.example.yaml config.yaml +``` + +2. Run a backup: + +```bash +./docker-compose-backup backup -c config.yaml +``` + +3. List configured projects and existing backups: + +```bash +./docker-compose-backup list -c config.yaml +``` + +4. Run as a daemon with cron scheduling: + +```bash +./docker-compose-backup daemon -c config.yaml +``` + +## systemd deployment + +This repository includes `docker-compose-backup.service`, configured for installation under `/opt/docker-compose-backup`: + +```bash +sudo mkdir -p /opt/docker-compose-backup +sudo cp docker-compose-backup-linux-arm64 /opt/docker-compose-backup/docker-compose-backup +sudo chmod +x /opt/docker-compose-backup/docker-compose-backup +sudo cp config.yaml /opt/docker-compose-backup/config.yaml +sudo mkdir -p /opt/docker-compose-backup/backups +sudo cp docker-compose-backup.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now docker-compose-backup +``` + +The example config uses `/opt/docker-compose-backup/backups`, which is allowed by the service sandbox. + +## Configuration + +See [config.example.yaml](config.example.yaml) for a full annotated example. + +```yaml +global: + backup_dir: /opt/docker-compose-backup/backups + temp_dir: /tmp/docker-backup # optional, defaults to OS temp + +projects: + - name: myapp + path: /opt/docker/myapp + compose_file: docker-compose.yml # default + cron: "0 3 * * *" # optional: daily at 3 AM, standard 5-field cron + exclude: + - ".git" + - "node_modules" + retention: + count: 7 # keep last 7 backups + days: 30 # optional: also delete >30 days + +# Optional: remote upload +# remote: +# s3: +# endpoint: https://s3.example.com +# bucket: my-backups +# access_key: ${S3_ACCESS_KEY} +# secret_key: ${S3_SECRET_KEY} +# region: us-east-1 +# path_prefix: docker-backups/ +# webdav: +# url: https://webdav.example.com/backups +# username: user +# password: ${WEBDAV_PASSWORD} +``` + +Environment variables in `${VAR}` form are expanded automatically. + +`exclude` uses Go `filepath.Match` patterns and also matches each file/directory basename. It is **not** full `.gitignore` syntax: `**` and negation rules like `!foo` are not supported. + +## Commands + +| Command | Description | +|------------|-------------------------------------------------------| +| `backup` | Run backup for all projects, print summary | +| `list` | List configured projects and their existing backups | +| `daemon` | Run as a background daemon with cron scheduling | + +Global flags: + +| Flag | Description | +|-----------------|--------------------------------| +| `-c, --config` | Path to config file (default: `config.yaml`) | +| `--dry-run` | Print actions without executing | +| `--version` | Print version | + +## Requirements + +- Docker CLI accessible on `PATH` +- Go 1.26+ (to build from source) \ No newline at end of file diff --git a/cmd/backup/backup.go b/cmd/backup/backup.go new file mode 100644 index 0000000..840a34d --- /dev/null +++ b/cmd/backup/backup.go @@ -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("──────────────────────────────────────────────") +} diff --git a/cmd/backup/daemon.go b/cmd/backup/daemon.go new file mode 100644 index 0000000..2672145 --- /dev/null +++ b/cmd/backup/daemon.go @@ -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()) +} \ No newline at end of file diff --git a/cmd/backup/list.go b/cmd/backup/list.go new file mode 100644 index 0000000..7fdf86e --- /dev/null +++ b/cmd/backup/list.go @@ -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 +} \ No newline at end of file diff --git a/cmd/backup/main.go b/cmd/backup/main.go new file mode 100644 index 0000000..6bb443b --- /dev/null +++ b/cmd/backup/main.go @@ -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) + } +} \ No newline at end of file diff --git a/cmd/backup/remote.go b/cmd/backup/remote.go new file mode 100644 index 0000000..07f60a0 --- /dev/null +++ b/cmd/backup/remote.go @@ -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 +} \ No newline at end of file diff --git a/cmd/backup/root.go b/cmd/backup/root.go new file mode 100644 index 0000000..e2b0634 --- /dev/null +++ b/cmd/backup/root.go @@ -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, +} \ No newline at end of file diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..e46bd12 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,35 @@ +# docker-compose-backup example configuration +global: + backup_dir: /opt/docker-compose-backup/backups + # temp_dir: /tmp/docker-compose-backup # defaults to OS temp dir + +projects: + - name: myapp + path: /opt/docker/myapp + compose_file: docker-compose.yml # default + cron: "0 3 * * *" # daily at 3 AM, standard 5-field cron + exclude: + - ".git" + - "node_modules" + retention: + count: 7 # keep last 7 backups + # days: 30 # optional: also delete older than N days + + - name: blog + path: /opt/docker/blog + cron: "0 */6 * * *" # every 6 hours + +# Remote upload (optional) — uncomment and configure as needed. +# remote: +# s3: +# endpoint: https://s3.example.com +# bucket: my-backups +# access_key: ${S3_ACCESS_KEY} +# secret_key: ${S3_SECRET_KEY} +# region: us-east-1 +# path_prefix: docker-backups/ +# +# webdav: +# url: https://webdav.example.com/backups +# username: backup-user +# password: ${WEBDAV_PASSWORD} \ No newline at end of file diff --git a/docker-compose-backup.service b/docker-compose-backup.service new file mode 100644 index 0000000..9bf486f --- /dev/null +++ b/docker-compose-backup.service @@ -0,0 +1,35 @@ +[Unit] +Description=Docker Compose Backup Daemon +After=network-online.target docker.service +Wants=network-online.target +Requires=docker.service + +[Service] +Type=simple +WorkingDirectory=/opt/docker-compose-backup +ExecStart=/opt/docker-compose-backup/docker-compose-backup daemon -c /opt/docker-compose-backup/config.yaml +Restart=on-failure +RestartSec=30 +TimeoutStopSec=60 +KillMode=mixed +KillSignal=SIGTERM + +# Security hardening +User=root +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +ReadWritePaths=/opt/docker-compose-backup /tmp +PrivateTmp=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictRealtime=yes + +# Logging +StandardOutput=journal +StandardError=journal +SyslogIdentifier=docker-compose-backup + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a804734 --- /dev/null +++ b/go.mod @@ -0,0 +1,43 @@ +module git.misaka.ren/M1saka/docker_backup + +go 1.26.4 + +require ( + github.com/aws/aws-sdk-go-v2 v1.42.1 + github.com/aws/aws-sdk-go-v2/config v1.32.27 + github.com/aws/aws-sdk-go-v2/credentials v1.19.26 + github.com/aws/aws-sdk-go-v2/service/s3 v1.104.2 + github.com/robfig/cron/v3 v3.0.1 + github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 +) + +require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.2.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.31.5 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.8 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.43.5 // indirect + github.com/aws/smithy-go v1.27.3 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.28.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3e123e7 --- /dev/null +++ b/go.sum @@ -0,0 +1,92 @@ +github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek= +github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E= +github.com/aws/aws-sdk-go-v2/config v1.32.27 h1:SJwJ9Q4kM7v5QVSYYyXj3znRr6lNyZEhSgAXmXXcVbI= +github.com/aws/aws-sdk-go-v2/config v1.32.27/go.mod h1:uBfrzTRedDmB2u+b6+UlaKJy2O6VSH5un2jP24t/KvQ= +github.com/aws/aws-sdk-go-v2/credentials v1.19.26 h1:Si8kk1kyJnuJWCEgiwpBtTdtgSdR7i611596NnC0YIQ= +github.com/aws/aws-sdk-go-v2/credentials v1.19.26/go.mod h1:lBckz+W9SAdNtSDw3pYgQUJDJFcBBWry0GSzw+bK0TY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 h1:/hi1JADLEW9YYryEz1w4GQu0EtP23pP553Cf9KgsDV4= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30/go.mod h1:/3AOgy4K17Dm4ucMZVC/MJkzy5kmfKUcINRHZyo0koQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 h1:3GUprIsfmGcC5SACIyB0e7E0BM1O1b3Erl5CePYIAeQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31/go.mod h1:7PuV1yl5e2xnUbm+RqvVg5i2iBM8EyijZNoI9wsOoOc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 h1:9Fjh6fi/U5JEStVZijmaMpUwE/gvBJj7x2B/PjbO9To= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23/go.mod h1:iMoT2f1tClxrWAAnKCXjZQ6LOmfLrMG14wmnWpM+F14= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 h1:uao4A3QZ5UmB326V6KF+qRpv9Tjz7IlnlnTbbANntlU= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31/go.mod h1:I/1+z0VwL1GhQyLgkoHDlygpUZ+iTAwOQ/NsftiUL2I= +github.com/aws/aws-sdk-go-v2/service/s3 v1.104.2 h1:bAY6O/TDv1HQnvylh9E247IyIKsUWUt2G965S7qX110= +github.com/aws/aws-sdk-go-v2/service/s3 v1.104.2/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do= +github.com/aws/aws-sdk-go-v2/service/signin v1.2.2 h1:69JEZSDTQ+UNbTWQJCZMmbpQb5sfc79KUt0O7Pyfjmo= +github.com/aws/aws-sdk-go-v2/service/signin v1.2.2/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.5 h1:xlK3Tdc8FO7Tq1k0+hL+otF33glj+dE+qeM5iINiDvU= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.5/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.8 h1:yX1IbiBfC7SdEgDwIGnRaZyPPDRbQPDOJxl8102PcGk= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.8/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.5 h1:T3ANO8QWDbzQD8f4+UaX+fvJlyGnOFMKLbW+NGBHg04= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.5/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q= +github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY= +github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/backup/copier.go b/internal/backup/copier.go new file mode 100644 index 0000000..25cdeea --- /dev/null +++ b/internal/backup/copier.go @@ -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()) +} \ No newline at end of file diff --git a/internal/backup/engine.go b/internal/backup/engine.go new file mode 100644 index 0000000..e50e6bb --- /dev/null +++ b/internal/backup/engine.go @@ -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 +} \ No newline at end of file diff --git a/internal/backup/executor.go b/internal/backup/executor.go new file mode 100644 index 0000000..efdb57e --- /dev/null +++ b/internal/backup/executor.go @@ -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 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 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 +} \ No newline at end of file diff --git a/internal/backup/format.go b/internal/backup/format.go new file mode 100644 index 0000000..6b310cf --- /dev/null +++ b/internal/backup/format.go @@ -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]) +} \ No newline at end of file diff --git a/internal/backup/retention.go b/internal/backup/retention.go new file mode 100644 index 0000000..42083f2 --- /dev/null +++ b/internal/backup/retention.go @@ -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") +} \ No newline at end of file diff --git a/internal/backup/retention_test.go b/internal/backup/retention_test.go new file mode 100644 index 0000000..aed702d --- /dev/null +++ b/internal/backup/retention_test.go @@ -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]) + } + } +} \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..0fefccc --- /dev/null +++ b/internal/config/config.go @@ -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 +} \ No newline at end of file diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..45fddd8 --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } +} \ No newline at end of file diff --git a/internal/config/loader.go b/internal/config/loader.go new file mode 100644 index 0000000..8badf71 --- /dev/null +++ b/internal/config/loader.go @@ -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 +} \ No newline at end of file diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go new file mode 100644 index 0000000..c33c74a --- /dev/null +++ b/internal/scheduler/scheduler.go @@ -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 +} \ No newline at end of file diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go new file mode 100644 index 0000000..89abf26 --- /dev/null +++ b/internal/scheduler/scheduler_test.go @@ -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") + } +} \ No newline at end of file diff --git a/internal/storage/s3.go b/internal/storage/s3.go new file mode 100644 index 0000000..742fb95 --- /dev/null +++ b/internal/storage/s3.go @@ -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 +} \ No newline at end of file diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..84c4b73 --- /dev/null +++ b/internal/storage/storage.go @@ -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 +} \ No newline at end of file diff --git a/internal/storage/webdav.go b/internal/storage/webdav.go new file mode 100644 index 0000000..2d76f98 --- /dev/null +++ b/internal/storage/webdav.go @@ -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)) +} \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..fec497d --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "git.misaka.ren/M1saka/docker_backup/cmd/backup" + +func main() { + backup.Execute() +} \ No newline at end of file