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