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