diff --git a/Dockerfile b/Dockerfile index 2809401..dfca6d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.24-alpine AS builder +FROM docker.io/library/golang:1.24-alpine AS builder WORKDIR /app RUN apk add --no-cache git @@ -39,9 +39,8 @@ COPY --from=builder /app/backea-server . COPY --from=builder /app/backup-performer . COPY --from=builder /app/list-backups . -COPY --from=builder /app/static ./static COPY --from=builder /app/templates ./templates RUN chmod +x /app/backea-server /app/backup-performer /app/list-backups -CMD ["/app/backea-server"] \ No newline at end of file +CMD ["/app/backea-server"] diff --git a/cmd/backup_performer/main.go b/cmd/backup_performer/main.go index ac4c4d4..7e5e9d9 100644 --- a/cmd/backup_performer/main.go +++ b/cmd/backup_performer/main.go @@ -1,7 +1,9 @@ package main import ( - "backea/internal/backup" + "backea/internal/backup/config" + "backea/internal/backup/core" + "backea/internal/backup/strategy" "context" "flag" "log" @@ -19,19 +21,28 @@ func main() { log.Printf("Warning: Error loading .env file: %v", err) } - ctx := context.Background() - - // Parse the service flag to extract group and index if provided + // Parse service name and index var serviceName, serviceIndex string if *serviceFlag != "" { - parts := strings.SplitN(*serviceFlag, "-", 2) + parts := strings.SplitN(*serviceFlag, ".", 2) serviceName = parts[0] if len(parts) > 1 { serviceIndex = parts[1] } } - if err := backup.PerformBackups(ctx, *configPath, serviceName, serviceIndex); err != nil { + cfg, err := config.LoadConfig(*configPath) + if err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + factory, _ := strategy.NewFactory(*configPath) + + executor := core.NewExecutor(cfg, *factory) + + // Perform backups + ctx := context.Background() + if err := executor.PerformBackups(ctx, serviceName, serviceIndex); err != nil { log.Fatalf("Backup failed: %v", err) } } diff --git a/cmd/list-backups/main.go b/cmd/list-backups/main.go index acb2f06..bb49583 100644 --- a/cmd/list-backups/main.go +++ b/cmd/list-backups/main.go @@ -1,7 +1,7 @@ package main import ( - "backea/internal/backup" + "backea/internal/backup/strategy" "context" "fmt" "log" @@ -20,12 +20,14 @@ func main() { log.Printf("Warning: Error loading .env file: %v", err) } - // Create backup factory - factory, err := backup.NewBackupFactory(configPath) + factoryBuilder := &strategy.DefaultFactoryBuilder{} + factory, err := factoryBuilder.BuildFactory(*configPath) if err != nil { log.Fatalf("Failed to create backup factory: %v", err) } + config := factory.Config + // Process each service group for groupName, serviceGroup := range factory.Config.Services { fmt.Printf("Service Group: %s (Directory: %s)\n", groupName, serviceGroup.Source.Path) diff --git a/compose.yaml b/compose.yaml index 4774f87..ba8b018 100644 --- a/compose.yaml +++ b/compose.yaml @@ -7,19 +7,17 @@ services: - "8080:8080" volumes: # Configuration files - - ./.env:/app/.env - - ./config.yml:/app/config.yml - - ./kopia.env:/app/kopia.env - + - ./.env:/app/.env:Z + - ./config.yml:/app/config.yml:Z + - ./kopia.env:/app/kopia.env:Z # Source directories to backup - map to identical paths inside container - - /home/gevo/Documents/backea2:/home/gevo/Documents/backea2 - - /home/gevo/Images:/home/gevo/Images + - /home/sirir/Images:/home/sirir/Images # Dedicated repository storage - - /home/gevo/.kopia:/root/.kopia + - ./kopiadata:/root/.kopia # SSH keys if needed - - /home/gevo/.ssh:/root/.ssh:ro + - /home/sirir/.ssh:/root/.ssh:ro - ./config/kopia:/tmp/kopia_configs environment: - HOME=/root @@ -31,4 +29,4 @@ services: - apparmor:unconfined restart: unless-stopped env_file: - - ./.env \ No newline at end of file + - ./.env diff --git a/config.yml b/config.yml index 5d7e4fe..73a13fd 100644 --- a/config.yml +++ b/config.yml @@ -11,7 +11,7 @@ services: backealocal: source: host: "local" - path: "/home/gevo/Documents/backea2" + path: "/home/sirir/Images" hooks: before_hook: "ls" after_hook: "" @@ -21,18 +21,18 @@ services: type: "kopia" provider: "local" destination: - path: "/home/gevo/Documents/backea2" + path: "/home/sirir/backup/image1" 2: backup_strategy: type: "kopia" provider: "local" destination: - path: "/home/gevo/Documents/backea2" + path: "/home/sirir/backup/images2" imageslocal: source: host: "local" - path: "/home/gevo/Images/" + path: "/home/sirir/Images/" hooks: before_hook: "ls" after_hook: "" @@ -43,16 +43,3 @@ services: provider: "local" destination: host: "local" - - b2backupstotozzeeeaaaeeee: - source: - host: "local" - path: "/home/gevo/Images/" - hooks: - before_hook: "ls" - after_hook: "" - backup_configs: - 1: - backup_strategy: - type: "kopia" - provider: "b2_backblaze" diff --git a/internal/backup/backup.go b/internal/backup/backup.go deleted file mode 100644 index e9c2d2f..0000000 --- a/internal/backup/backup.go +++ /dev/null @@ -1,131 +0,0 @@ -package backup - -import ( - "backea/internal/mail" - "context" - "fmt" - "log" - "sync" -) - -// PerformBackups executes backups for multiple services based on configuration -func PerformBackups(ctx context.Context, configPath string, serviceName string, serviceIndex string) error { - // Create backup factory - factory, err := NewBackupFactory(configPath) - if err != nil { - return err - } - - // Initialize mailer - mailer := mail.NewMailer() - - // Process services - if serviceName != "" { - // Process single service group or specific service - if serviceIndex != "" { - // Process specific service within a group - return processSpecificService(ctx, factory, serviceName, serviceIndex, mailer) - } else { - // Process all services in the specified group - return processServiceGroup(ctx, factory, serviceName, mailer) - } - } else { - // Process all service groups in parallel - var wg sync.WaitGroup - errs := make(chan error, len(factory.Config.Services)) - for groupName := range factory.Config.Services { - wg.Add(1) - go func(group string) { - defer wg.Done() - if err := processServiceGroup(ctx, factory, group, mailer); err != nil { - log.Printf("Failed to backup service group %s: %v", group, err) - errs <- fmt.Errorf("backup failed for group %s: %w", group, err) - } - }(groupName) - } - // Wait for all backups to complete - wg.Wait() - close(errs) - - // Check if any errors occurred - var lastErr error - for err := range errs { - lastErr = err - } - return lastErr - } -} - -// processServiceGroup handles the backup for all services in a group -func processServiceGroup(ctx context.Context, factory *BackupFactory, groupName string, mailer *mail.Mailer) error { - // Get service group configuration - serviceGroup, exists := factory.Config.Services[groupName] - if !exists { - log.Printf("Service group not found: %s", groupName) - return nil - } - - // Execute the before hook once for the entire group - if serviceGroup.Hooks.BeforeHook != "" { - log.Printf("Executing before hook for group %s: %s", groupName, serviceGroup.Hooks.BeforeHook) - if err := RunCommand(serviceGroup.Source.Path, serviceGroup.Hooks.BeforeHook); err != nil { - log.Printf("Failed to execute before hook for group %s: %v", groupName, err) - return err - } - } - - // Process all services in the group in parallel - var wg sync.WaitGroup - errs := make(chan error, len(serviceGroup.BackupConfigs)) - for configIndex := range serviceGroup.BackupConfigs { - wg.Add(1) - go func(group, index string) { - defer wg.Done() - if err := processSpecificService(ctx, factory, group, index, mailer); err != nil { - log.Printf("Failed to backup service %s.%s: %v", group, index, err) - errs <- fmt.Errorf("backup failed for %s.%s: %w", group, index, err) - } - }(groupName, configIndex) - } - // Wait for all backups to complete - wg.Wait() - close(errs) - - // Execute the after hook once for the entire group - if serviceGroup.Hooks.AfterHook != "" { - log.Printf("Executing after hook for group %s: %s", groupName, serviceGroup.Hooks.AfterHook) - if err := RunCommand(serviceGroup.Source.Path, serviceGroup.Hooks.AfterHook); err != nil { - log.Printf("Failed to execute after hook for group %s: %v", groupName, err) - // We don't return here because we want to process the errors from the backups - } - } - - // Check if any errors occurred - var lastErr error - for err := range errs { - lastErr = err - } - return lastErr -} - -// processSpecificService handles the backup for a specific service in a group -func processSpecificService(ctx context.Context, factory *BackupFactory, groupName string, configIndex string, mailer *mail.Mailer) error { - // Get service configuration - serviceGroup := factory.Config.Services[groupName] - - // Create the appropriate backup strategy using the factory - strategy, err := factory.CreateBackupStrategyForService(groupName, configIndex) - if err != nil { - log.Printf("Failed to create backup strategy for service %s.%s: %v", groupName, configIndex, err) - return err - } - - // Create and run service - service := NewService( - fmt.Sprintf("%s.%s", groupName, configIndex), - serviceGroup.Source.Path, - strategy, - mailer, - ) - return service.Backup(ctx) -} diff --git a/internal/backup/backup_factory.go b/internal/backup/backup_factory.go deleted file mode 100644 index 6b787a1..0000000 --- a/internal/backup/backup_factory.go +++ /dev/null @@ -1,187 +0,0 @@ -package backup - -import ( - "fmt" - "log" - "os" - "os/exec" - "path/filepath" - "strings" - "time" -) - -// BackupFactory creates backup strategies and managers -type BackupFactory struct { - Config *Configuration - ConfigPath string -} - -// NewBackupFactory creates a new backup factory -func NewBackupFactory(configPath string) (*BackupFactory, error) { - // Load configuration - config, err := LoadConfig(configPath) - if err != nil { - return nil, fmt.Errorf("could not load config: %w", err) - } - return &BackupFactory{ - Config: config, - ConfigPath: configPath, - }, nil -} - -// CreateKopiaStrategy creates a new Kopia backup strategy with specific retention settings and provider -func (f *BackupFactory) CreateKopiaStrategy(retention Retention, provider KopiaProvider) *KopiaStrategy { - // Create temp directory for Kopia configs if it doesn't exist - tmpConfigDir := filepath.Join(os.TempDir(), "kopia_configs") - if err := os.MkdirAll(tmpConfigDir, 0755); err != nil { - log.Printf("Warning: failed to create temp config directory: %v", err) - // Fall back to default config path - tmpConfigDir = os.TempDir() - } - - // Generate a unique config file path for this instance - configPath := filepath.Join(tmpConfigDir, fmt.Sprintf("kopia_%s_%d.config", - provider.GetProviderType(), time.Now().UnixNano())) - - // Need to update this line to pass configPath - return NewKopiaStrategy(retention, provider, configPath) -} - -// CreateKopiaProvider creates the appropriate Kopia provider based on config -func (f *BackupFactory) CreateKopiaProvider(strategyConfig StrategyConfig) (KopiaProvider, error) { - switch strategyConfig.Provider { - case "b2_backblaze": - return NewKopiaB2Provider(), nil - case "local": - // Extract options for local path if specified - basePath := "" - if strategyConfig.Options != "" { - basePath = strategyConfig.Options - } else { - // Default to ~/.backea/repos if not specified - basePath = filepath.Join(os.Getenv("HOME"), ".backea", "repos") - } - return NewKopiaLocalProvider(basePath), nil - case "sftp": - // Parse options for SFTP - expected format: "user@host:/path/to/backups" - host := strategyConfig.Destination.Host - path := strategyConfig.Destination.Path - sshKey := strategyConfig.Destination.SSHKey - - if host == "" { - return nil, fmt.Errorf("SFTP provider requires host in destination config") - } - if path == "" { - return nil, fmt.Errorf("SFTP provider requires path in destination config") - } - - // Default SSH key if not specified - if sshKey == "" { - sshKey = filepath.Join(os.Getenv("HOME"), ".ssh", "id_rsa") - } - - // Extract username from host if in format user@host - username := "root" // default - if hostParts := strings.Split(host, "@"); len(hostParts) > 1 { - username = hostParts[0] - host = hostParts[1] - } - - return NewKopiaSFTPProvider(host, path, username, sshKey), nil - default: - return nil, fmt.Errorf("unsupported Kopia provider: %s", strategyConfig.Provider) - } -} - -// CreateBackupStrategyForService creates a backup strategy for the specified service within a group -func (f *BackupFactory) CreateBackupStrategyForService(groupName string, serviceIndex string) (Strategy, error) { - // Find service group - serviceGroup, exists := f.Config.Services[groupName] - if !exists { - return nil, fmt.Errorf("service group not found: %s", groupName) - } - - // Find specific backup config within the group - backupConfig, exists := serviceGroup.BackupConfigs[serviceIndex] - if !exists { - return nil, fmt.Errorf("backup config not found: %s.%s", groupName, serviceIndex) - } - - // Create appropriate strategy based on type - strategyConfig := backupConfig.BackupStrategy - - // Extract retention settings once - retention := Retention{ - KeepLatest: strategyConfig.Retention.KeepLatest, - KeepHourly: strategyConfig.Retention.KeepHourly, - KeepDaily: strategyConfig.Retention.KeepDaily, - KeepWeekly: strategyConfig.Retention.KeepWeekly, - KeepMonthly: strategyConfig.Retention.KeepMonthly, - KeepYearly: strategyConfig.Retention.KeepYearly, - } - - switch strategyConfig.Type { - case "kopia": - // Create appropriate provider based on configuration - provider, err := f.CreateKopiaProvider(strategyConfig) - if err != nil { - return nil, fmt.Errorf("failed to create kopia provider: %w", err) - } - return f.CreateKopiaStrategy(retention, provider), nil - case "rsync": - // Uncomment when rsync implementation is ready - // return NewRsyncStrategy( - // strategyConfig.Options, - // Destination{ - // Host: strategyConfig.Destination.Host, - // Path: strategyConfig.Destination.Path, - // SSHKey: strategyConfig.Destination.SSHKey, - // }, - // ), nil - fallthrough - default: - // Default to local kopia if type is unknown or rsync (temporarily) - provider, _ := f.CreateKopiaProvider(StrategyConfig{Provider: "local"}) - return f.CreateKopiaStrategy(retention, provider), nil - } -} - -// ExecuteGroupHooks runs the hooks for a service group -func (f *BackupFactory) ExecuteGroupHooks(groupName string, isBeforeHook bool) error { - serviceGroup, exists := f.Config.Services[groupName] - if !exists { - return fmt.Errorf("service group not found: %s", groupName) - } - - var hook string - if isBeforeHook { - hook = serviceGroup.Hooks.BeforeHook - } else { - hook = serviceGroup.Hooks.AfterHook - } - - if hook == "" { - return nil - } - - // Execute the hook - cmd := exec.Command("sh", "-c", hook) - output, err := cmd.CombinedOutput() - - if err != nil { - return fmt.Errorf("hook execution failed: %w, output: %s", err, string(output)) - } - - log.Printf("Hook executed successfully: %s, output: %s", hook, string(output)) - return nil -} - -// ReloadConfig refreshes the configuration from the config file -func (f *BackupFactory) ReloadConfig() error { - config, err := LoadConfig(f.ConfigPath) - if err != nil { - return fmt.Errorf("could not reload config: %w", err) - } - f.Config = config - return nil -} diff --git a/internal/backup/backup_rsync.go b/internal/backup/backup_rsync.go deleted file mode 100644 index 8dec2c9..0000000 --- a/internal/backup/backup_rsync.go +++ /dev/null @@ -1,51 +0,0 @@ -package backup - -import ( - "context" - "fmt" - "log" - "os" - "os/exec" -) - -// RsyncStrategy implements the backup strategy using rsync -type RsyncStrategy struct { - Options string - Destination Destination -} - -// NewRsyncStrategy creates a new rsync strategy -func NewRsyncStrategy(options string, destination Destination) *RsyncStrategy { - return &RsyncStrategy{ - Options: options, - Destination: destination, - } -} - -// Execute performs the rsync backup -func (s *RsyncStrategy) Execute(ctx context.Context, serviceName, directory string) error { - log.Printf("Performing rsync backup for %s", serviceName) - - // Build rsync command - dst := s.Destination - sshKeyOption := "" - - // Without - if dst.SSHKey != "" { - sshKeyOption = fmt.Sprintf("-e 'ssh -i %s'", dst.SSHKey) - } - - destination := fmt.Sprintf("%s:%s", dst.Host, dst.Path) - rsyncCmd := fmt.Sprintf("rsync %s %s %s %s", - s.Options, - sshKeyOption, - directory, - destination) - - // Run rsync command - log.Printf("Running: %s", rsyncCmd) - cmd := exec.CommandContext(ctx, "sh", "-c", rsyncCmd) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} diff --git a/internal/backup/config.go b/internal/backup/config.go deleted file mode 100644 index d436a04..0000000 --- a/internal/backup/config.go +++ /dev/null @@ -1,112 +0,0 @@ -package backup - -import ( - "log" - - "github.com/joho/godotenv" - "github.com/spf13/viper" -) - -// Configuration holds the complete application configuration -type Configuration struct { - Defaults DefaultsConfig `mapstructure:"defaults"` - Services map[string]ServiceGroup `mapstructure:"services"` -} - -// DefaultsConfig holds the default settings for all services -type DefaultsConfig struct { - Retention RetentionConfig `mapstructure:"retention"` -} - -// ServiceGroup represents a service group with a common directory and multiple backup configurations -type ServiceGroup struct { - Directory string `mapstructure:"directory,omitempty"` - Source SourceConfig `mapstructure:"source"` - Hooks HooksConfig `mapstructure:"hooks"` - BackupConfigs map[string]BackupConfig `mapstructure:"backup_configs"` -} - -// SourceConfig represents source configuration for backups -type SourceConfig struct { - Host string `mapstructure:"host"` - Path string `mapstructure:"path"` - SSHKey string `mapstructure:"ssh_key"` -} - -// HooksConfig contains the hooks to run before and after the backup -type HooksConfig struct { - BeforeHook string `mapstructure:"before_hook"` - AfterHook string `mapstructure:"after_hook"` -} - -// BackupConfig represents a specific backup configuration -type BackupConfig struct { - BackupStrategy StrategyConfig `mapstructure:"backup_strategy"` -} - -// StrategyConfig represents a backup strategy configuration -type StrategyConfig struct { - Type string `mapstructure:"type"` - Provider string `mapstructure:"provider"` - Options string `mapstructure:"options,omitempty"` - Retention RetentionConfig `mapstructure:"retention,omitempty"` - Destination DestConfig `mapstructure:"destination"` -} - -// RetentionConfig represents retention policy configuration -type RetentionConfig struct { - KeepLatest int `mapstructure:"keep_latest"` - KeepHourly int `mapstructure:"keep_hourly"` - KeepDaily int `mapstructure:"keep_daily"` - KeepWeekly int `mapstructure:"keep_weekly"` - KeepMonthly int `mapstructure:"keep_monthly"` - KeepYearly int `mapstructure:"keep_yearly"` -} - -// DestConfig represents destination configuration for remote backups -type DestConfig struct { - Host string `mapstructure:"host,omitempty"` - Path string `mapstructure:"path"` - SSHKey string `mapstructure:"ssh_key,omitempty"` -} - -// LoadConfig loads the application configuration from a file -func LoadConfig(configPath string) (*Configuration, error) { - if err := godotenv.Load(); err != nil { - log.Printf("Warning: Error loading .env file: %v", err) - } - - viper.SetConfigFile(configPath) - if err := viper.ReadInConfig(); err != nil { - return nil, err - } - - var config Configuration - if err := viper.Unmarshal(&config); err != nil { - return nil, err - } - - // Apply default retention settings where needed - for serviceName, service := range config.Services { - for configID, backupConfig := range service.BackupConfigs { - // If retention is not set, use defaults - if isRetentionEmpty(backupConfig.BackupStrategy.Retention) { - backupConfig.BackupStrategy.Retention = config.Defaults.Retention - service.BackupConfigs[configID] = backupConfig - } - } - config.Services[serviceName] = service - } - - return &config, nil -} - -// isRetentionEmpty checks if a retention config is empty (all zeros) -func isRetentionEmpty(retention RetentionConfig) bool { - return retention.KeepLatest == 0 && - retention.KeepHourly == 0 && - retention.KeepDaily == 0 && - retention.KeepWeekly == 0 && - retention.KeepMonthly == 0 && - retention.KeepYearly == 0 -} diff --git a/internal/backup/core/executor.go b/internal/backup/core/executor.go new file mode 100644 index 0000000..3ae0d43 --- /dev/null +++ b/internal/backup/core/executor.go @@ -0,0 +1,176 @@ +package core + +import ( + "backea/internal/backup/config" + "backea/internal/backup/strategy" + "backea/internal/mail" + "context" + "fmt" + "log" + "sync" +) + +// Executor handles the execution of backups for multiple services +type Executor struct { + Config *config.Configuration + Factory strategy.Factory + Mailer *mail.Mailer + concurrency int +} + +// NewExecutor creates a new backup executor +func NewExecutor(config *config.Configuration, factory strategy.Factory) *Executor { + return &Executor{ + Config: config, + Factory: factory, + // Mailer: mailer, + concurrency: 5, + } +} + +// SetConcurrency sets the maximum number of concurrent backups +func (e *Executor) SetConcurrency(n int) { + if n > 0 { + e.concurrency = n + } +} + +// PerformBackups executes backups for multiple services based on configuration +func (e *Executor) PerformBackups(ctx context.Context, serviceName string, serviceIndex string) error { + // Process services based on parameters + if serviceName != "" { + // Process single service group or specific service + if serviceIndex != "" { + // Process specific service within a group + return e.processSpecificService(ctx, serviceName, serviceIndex) + } else { + // Process all services in the specified group + return e.processServiceGroup(ctx, serviceName) + } + } else { + // Process all service groups in parallel + return e.processAllServiceGroups(ctx) + } +} + +// processAllServiceGroups processes all service groups in parallel +func (e *Executor) processAllServiceGroups(ctx context.Context) error { + var wg sync.WaitGroup + errs := make(chan error, len(e.Config.Services)) + + // Create a semaphore to limit concurrency + sem := make(chan struct{}, e.concurrency) + + for groupName := range e.Config.Services { + wg.Add(1) + sem <- struct{}{} // Acquire semaphore + go func(group string) { + defer wg.Done() + defer func() { <-sem }() // Release semaphore + + if err := e.processServiceGroup(ctx, group); err != nil { + log.Printf("Failed to backup service group %s: %v", group, err) + errs <- fmt.Errorf("backup failed for group %s: %w", group, err) + } + }(groupName) + } + + // Wait for all backups to complete + wg.Wait() + close(errs) + + // Check if any errors occurred and return the last one + var lastErr error + for err := range errs { + lastErr = err + } + return lastErr +} + +// processServiceGroup handles the backup for all services in a group +func (e *Executor) processServiceGroup(ctx context.Context, groupName string) error { + // Get service group configuration + serviceGroup, exists := e.Config.Services[groupName] + if !exists { + log.Printf("Service group not found: %s", groupName) + return fmt.Errorf("service group not found: %s", groupName) + } + + // Create hook runner + hooks := NewHookRunner(serviceGroup.Source.Path, serviceGroup.Hooks.BeforeHook, serviceGroup.Hooks.AfterHook) + + // Execute the before hook once for the entire group + if err := hooks.RunBeforeHook(); err != nil { + log.Printf("Failed to execute before hook for group %s: %v", groupName, err) + return fmt.Errorf("before hook failed: %w", err) + } + + // Process all services in the group in parallel + var wg sync.WaitGroup + errs := make(chan error, len(serviceGroup.BackupConfigs)) + + // Create a semaphore to limit concurrency within the group + sem := make(chan struct{}, e.concurrency) + + for configIndex := range serviceGroup.BackupConfigs { + wg.Add(1) + sem <- struct{}{} // Acquire semaphore + go func(group, index string) { + defer wg.Done() + defer func() { <-sem }() // Release semaphore + + if err := e.processSpecificService(ctx, group, index); err != nil { + log.Printf("Failed to backup service %s.%s: %v", group, index, err) + errs <- fmt.Errorf("backup failed for %s.%s: %w", group, index, err) + } + }(groupName, configIndex) + } + + // Wait for all backups to complete + wg.Wait() + close(errs) + + // Execute the after hook once for the entire group + if err := hooks.RunAfterHook(); err != nil { + log.Printf("Failed to execute after hook for group %s: %v", groupName, err) + // Don't return here because we want to report backup errors as well + } + + // Check if any errors occurred + var lastErr error + for err := range errs { + lastErr = err + } + return lastErr +} + +// processSpecificService handles the backup for a specific service in a group +func (e *Executor) processSpecificService(ctx context.Context, groupName string, configIndex string) error { + // Get service configuration + serviceGroup, exists := e.Config.Services[groupName] + if !exists { + return fmt.Errorf("service group not found: %s", groupName) + } + + // Check if the config index exists + _, exists = serviceGroup.BackupConfigs[configIndex] + if !exists { + return fmt.Errorf("service index not found: %s.%s", groupName, configIndex) + } + + // Create the appropriate backup strategy using the factory + backupStrategy, err := e.Factory.CreateBackupStrategyForService(groupName, configIndex) + if err != nil { + log.Printf("Failed to create backup strategy for service %s.%s: %v", groupName, configIndex, err) + return fmt.Errorf("failed to create backup strategy: %w", err) + } + + // Create and run service + service := NewService( + fmt.Sprintf("%s.%s", groupName, configIndex), + serviceGroup.Source.Path, + backupStrategy, + e.Mailer, + ) + return service.Backup(ctx) +} diff --git a/internal/backup/core/hooks.go b/internal/backup/core/hooks.go new file mode 100644 index 0000000..ae5a46a --- /dev/null +++ b/internal/backup/core/hooks.go @@ -0,0 +1,56 @@ +package core + +import ( + "log" + "os" + "os/exec" +) + +// HookRunner runs hooks before and after backups +type HookRunner struct { + Directory string + BeforeHook string + AfterHook string +} + +// NewHookRunner creates a new hook runner +func NewHookRunner(directory, beforeHook, afterHook string) *HookRunner { + return &HookRunner{ + Directory: directory, + BeforeHook: beforeHook, + AfterHook: afterHook, + } +} + +// RunBeforeHook executes the before hook if defined +func (h *HookRunner) RunBeforeHook() error { + if h.BeforeHook == "" { + return nil + } + + log.Printf("Running before hook: %s", h.BeforeHook) + return h.runCommand(h.BeforeHook) +} + +// RunAfterHook executes the after hook if defined +func (h *HookRunner) RunAfterHook() error { + if h.AfterHook == "" { + return nil + } + + log.Printf("Running after hook: %s", h.AfterHook) + return h.runCommand(h.AfterHook) +} + +// runCommand executes a shell command in the specified directory +func (h *HookRunner) runCommand(command string) error { + cmd := exec.Command("sh", "-c", command) + if h.Directory != "" { + cmd.Dir = h.Directory + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + log.Printf("Executing command in %s: %s", h.Directory, command) + return cmd.Run() +} diff --git a/internal/backup/service.go b/internal/backup/core/service.go similarity index 57% rename from internal/backup/service.go rename to internal/backup/core/service.go index 48851fe..8acaf3f 100644 --- a/internal/backup/service.go +++ b/internal/backup/core/service.go @@ -1,12 +1,13 @@ -package backup +package core import ( + "backea/internal/backup/models" + "backea/internal/backup/strategy" "backea/internal/mail" "context" "fmt" "log" "os" - "os/exec" "time" ) @@ -14,12 +15,12 @@ import ( type Service struct { Name string Directory string - Strategy Strategy + Strategy strategy.Strategy Mailer *mail.Mailer } // NewService creates a new backup service -func NewService(name string, directory string, strategy Strategy, mailer *mail.Mailer) *Service { +func NewService(name string, directory string, strategy strategy.Strategy, mailer *mail.Mailer) *Service { return &Service{ Name: name, Directory: directory, @@ -58,14 +59,18 @@ func (s *Service) Backup(ctx context.Context) error { return nil } -// RunCommand executes a shell command in the specified directory -// This is now a package function rather than a method on Service -func RunCommand(dir, command string) error { - cmd := exec.Command("sh", "-c", command) - if dir != "" { - cmd.Dir = dir - } - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() +// GetBackupInfos retrieves information about existing backups +func (s *Service) GetBackupInfos(ctx context.Context) ([]models.BackupInfo, error) { + return s.Strategy.ListBackups(ctx, s.Name) +} + +// GetStorageUsage retrieves information about storage usage +func (s *Service) GetStorageUsage(ctx context.Context) (*models.StorageUsageInfo, error) { + return s.Strategy.GetStorageUsage(ctx, s.Name) +} + +// RestoreBackup restores a backup by ID +func (s *Service) RestoreBackup(ctx context.Context, backupID string) error { + log.Printf("Restoring backup %s for service %s", backupID, s.Name) + return s.Strategy.RestoreBackup(ctx, backupID, s.Name) } diff --git a/internal/backup/kopia_providers.go b/internal/backup/kopia_providers.go deleted file mode 100644 index ce9f345..0000000 --- a/internal/backup/kopia_providers.go +++ /dev/null @@ -1,277 +0,0 @@ -package backup - -import ( - b2_client "backea/internal/client" - "context" - "fmt" - "log" - "os" - "os/exec" - "path/filepath" - "strings" -) - -// KopiaProvider defines the interface for different Kopia storage backends -type KopiaProvider interface { - // Connect connects to an existing repository or creates a new one - Connect(ctx context.Context, serviceName string, password string, configPath string) error - // GetRepositoryParams returns the parameters needed for repository operations - GetRepositoryParams(serviceName string) ([]string, error) - // GetBucketName returns the storage identifier (bucket name or path) - GetBucketName(serviceName string) string - // GetProviderType returns a string identifying the provider type - GetProviderType() string -} - -// KopiaB2Provider implements the KopiaProvider interface for Backblaze B2 -type KopiaB2Provider struct{} - -// NewKopiaB2Provider creates a new B2 provider -func NewKopiaB2Provider() *KopiaB2Provider { - return &KopiaB2Provider{} -} - -// Connect connects to a B2 repository -func (p *KopiaB2Provider) Connect(ctx context.Context, serviceName string, password string, configPath string) error { - // Load environment variables for B2 credentials - keyID := os.Getenv("B2_KEY_ID") - applicationKey := os.Getenv("B2_APPLICATION_KEY") - if keyID == "" || applicationKey == "" { - return fmt.Errorf("B2_KEY_ID and B2_APPLICATION_KEY must be set in .env file") - } - - bucketName := p.GetBucketName(serviceName) - - // Create B2 client and check if bucket exists, create if not - B2Client, _ := b2_client.NewClientFromEnv() - if B2Client == nil { - return fmt.Errorf("B2 client not initialized") - } - - _, err := B2Client.GetBucket(ctx, bucketName) - if err != nil { - log.Printf("Bucket %s not found, creating...", bucketName) - _, err = B2Client.CreateBucket(ctx, bucketName, false) - if err != nil { - return fmt.Errorf("failed to create bucket: %w", err) - } - log.Printf("Created bucket: %s", bucketName) - } - - // Try to connect to existing repository with config file - connectCmd := exec.CommandContext( - ctx, - "kopia", - "--config-file", configPath, - "repository", "connect", "b2", - "--bucket", bucketName, - "--key-id", keyID, - "--key", applicationKey, - "--password", password, - ) - err = connectCmd.Run() - if err != nil { - // Connection failed, create new repository - log.Printf("Creating new B2 repository for %s", serviceName) - createCmd := exec.CommandContext( - ctx, - "kopia", - "--config-file", configPath, - "repository", "create", "b2", - "--bucket", bucketName, - "--key-id", keyID, - "--key", applicationKey, - "--password", password, - ) - createOutput, err := createCmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to create repository: %w\nOutput: %s", err, createOutput) - } - } - - return nil -} - -// GetRepositoryParams returns parameters for B2 operations -func (p *KopiaB2Provider) GetRepositoryParams(serviceName string) ([]string, error) { - keyID := os.Getenv("B2_KEY_ID") - applicationKey := os.Getenv("B2_APPLICATION_KEY") - if keyID == "" || applicationKey == "" { - return nil, fmt.Errorf("B2_KEY_ID and B2_APPLICATION_KEY must be set in .env file") - } - - bucketName := p.GetBucketName(serviceName) - - return []string{ - "b2", - "--bucket", bucketName, - "--key-id", keyID, - "--key", applicationKey, - }, nil -} - -// Modify the GetBucketName method in your KopiaB2Provider -func (p *KopiaB2Provider) GetBucketName(serviceName string) string { - // Backblaze dont allow . - sanitized := strings.ReplaceAll(serviceName, ".", "-") - - // Add a prefix to make the bucket name unique - return fmt.Sprintf("backea-%s", sanitized) -} - -// GetProviderType returns the provider type identifier -func (p *KopiaB2Provider) GetProviderType() string { - return "b2" -} - -// KopiaLocalProvider implements the KopiaProvider interface for local storage -type KopiaLocalProvider struct { - BasePath string -} - -// NewKopiaLocalProvider creates a new local provider -func NewKopiaLocalProvider(basePath string) *KopiaLocalProvider { - // If basePath is empty, use a default location - if basePath == "" { - basePath = filepath.Join(os.Getenv("HOME"), ".backea", "repos") - } - return &KopiaLocalProvider{ - BasePath: basePath, - } -} - -// Connect connects to a local repository -func (p *KopiaLocalProvider) Connect(ctx context.Context, serviceName string, password string, configPath string) error { - repoPath := filepath.Join(p.BasePath, serviceName) - log.Printf("Connecting to local repository at %s with config: %s", repoPath, configPath) - - // Ensure the directory exists - if err := os.MkdirAll(repoPath, 0755); err != nil { - return fmt.Errorf("failed to create repository directory: %w", err) - } - - // Try to connect to existing repository with config file - connectCmd := exec.CommandContext( - ctx, - "kopia", - "--config-file", configPath, - "repository", "connect", "filesystem", - "--path", repoPath, - "--password", password, - ) - err := connectCmd.Run() - if err != nil { - // Connection failed, create new repository - log.Printf("Creating new local repository for %s at destination: %s", serviceName, repoPath) - createCmd := exec.CommandContext( - ctx, - "kopia", - "--config-file", configPath, - "repository", "create", "filesystem", - "--path", repoPath, - "--password", password, - ) - createOutput, err := createCmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to create repository: %w\nOutput: %s", err, createOutput) - } - } - - return nil -} - -// GetRepositoryParams returns parameters for local filesystem operations -func (p *KopiaLocalProvider) GetRepositoryParams(serviceName string) ([]string, error) { - repoPath := filepath.Join(p.BasePath, serviceName) - return []string{ - "filesystem", - "--path", repoPath, - }, nil -} - -// GetBucketName returns the path for a service -func (p *KopiaLocalProvider) GetBucketName(serviceName string) string { - return filepath.Join(p.BasePath, serviceName) -} - -// GetProviderType returns the provider type identifier -func (p *KopiaLocalProvider) GetProviderType() string { - return "local" -} - -// KopiaSFTPProvider implements the KopiaProvider interface for SFTP remote storage -type KopiaSFTPProvider struct { - Host string - BasePath string - Username string - KeyFile string -} - -// NewKopiaSFTPProvider creates a new SFTP provider -func NewKopiaSFTPProvider(host, basePath, username, keyFile string) *KopiaSFTPProvider { - return &KopiaSFTPProvider{ - Host: host, - BasePath: basePath, - Username: username, - KeyFile: keyFile, - } -} - -// Connect connects to an SFTP repository -func (p *KopiaSFTPProvider) Connect(ctx context.Context, serviceName string, password string, configPath string) error { - repoPath := fmt.Sprintf("%s@%s:%s/%s", p.Username, p.Host, p.BasePath, serviceName) - - // Try to connect to existing repository with config file - connectCmd := exec.CommandContext( - ctx, - "kopia", - "--config-file", configPath, - "repository", "connect", "sftp", - "--path", repoPath, - "--keyfile", p.KeyFile, - "--known-hosts", filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts"), - "--password", password, - ) - err := connectCmd.Run() - if err != nil { - // Connection failed, create new repository - log.Printf("Creating new SFTP repository for %s at %s", serviceName, repoPath) - createCmd := exec.CommandContext( - ctx, - "kopia", - "--config-file", configPath, - "repository", "create", "sftp", - "--path", repoPath, - "--keyfile", p.KeyFile, - "--known-hosts", filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts"), - "--password", password, - ) - createOutput, err := createCmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to create repository: %w\nOutput: %s", err, createOutput) - } - } - - return nil -} - -// GetRepositoryParams returns parameters for SFTP operations -func (p *KopiaSFTPProvider) GetRepositoryParams(serviceName string) ([]string, error) { - repoPath := fmt.Sprintf("%s@%s:%s/%s", p.Username, p.Host, p.BasePath, serviceName) - return []string{ - "sftp", - "--path", repoPath, - "--keyfile", p.KeyFile, - "--known-hosts", filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts"), - }, nil -} - -// GetBucketName returns the path for a service -func (p *KopiaSFTPProvider) GetBucketName(serviceName string) string { - return fmt.Sprintf("%s/%s", p.BasePath, serviceName) -} - -// GetProviderType returns the provider type identifier -func (p *KopiaSFTPProvider) GetProviderType() string { - return "sftp" -} diff --git a/internal/backup/models/backup.go b/internal/backup/models/backup.go new file mode 100644 index 0000000..aa49689 --- /dev/null +++ b/internal/backup/models/backup.go @@ -0,0 +1,15 @@ +package models + +import ( + "time" +) + +// BackupInfo contains information about a single backup +type BackupInfo struct { + ID string // Unique identifier for the backup + CreationTime time.Time // When the backup was created + Size int64 // Size in bytes + Source string // Original source path + Type string // Type of backup (e.g., "kopia", "restic") + RetentionTag string // Retention policy applied (e.g., "latest", "daily", "weekly") +} diff --git a/internal/backup/models/storage.go b/internal/backup/models/storage.go new file mode 100644 index 0000000..5558be0 --- /dev/null +++ b/internal/backup/models/storage.go @@ -0,0 +1,8 @@ +package models + +// StorageUsageInfo contains information about backup storage usage +type StorageUsageInfo struct { + TotalBytes int64 // Total bytes stored + Provider string // Storage provider (e.g., "local", "b2", "s3") + ProviderID string // Provider-specific ID (bucket name, path, etc.) +} diff --git a/internal/backup/restore.go b/internal/backup/restore.go deleted file mode 100644 index 0632f98..0000000 --- a/internal/backup/restore.go +++ /dev/null @@ -1,179 +0,0 @@ -package backup - -import ( - client "backea/internal/client" - "bufio" - "context" - "fmt" - "log" - "os" - "os/exec" - "path/filepath" - "strings" -) - -// RestoreManager handles restoring files from Kopia backups -type RestoreManager struct { - B2Client *client.Client -} - -// NewRestoreManager creates a new restore manager -func NewRestoreManager(b2Client *client.Client) *RestoreManager { - return &RestoreManager{ - B2Client: b2Client, - } -} - -// getStoredPassword retrieves a password from kopia.env file -func (r *RestoreManager) getStoredPassword(serviceName string) (string, error) { - // Define the expected key in the env file - passwordKey := fmt.Sprintf("KOPIA_%s_PASSWORD", strings.ToUpper(serviceName)) - - // Try to read from kopia.env - kopiaEnvPath := "kopia.env" - if _, err := os.Stat(kopiaEnvPath); err != nil { - return "", fmt.Errorf("kopia.env file not found: %w", err) - } - - // File exists, check if the password is there - file, err := os.Open(kopiaEnvPath) - if err != nil { - return "", fmt.Errorf("failed to open kopia.env: %w", err) - } - defer file.Close() - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := scanner.Text() - if strings.HasPrefix(line, passwordKey+"=") { - parts := strings.SplitN(line, "=", 2) - if len(parts) == 2 { - // Found the password, remove quotes if present - password := parts[1] - password = strings.Trim(password, "\"") - return password, nil - } - } - } - - if err := scanner.Err(); err != nil { - return "", fmt.Errorf("error reading kopia.env: %w", err) - } - - return "", fmt.Errorf("password for service %s not found in kopia.env", serviceName) -} - -// connectToRepository connects to an existing Kopia repository -func (r *RestoreManager) connectToRepository(ctx context.Context, serviceName string) error { - if r.B2Client == nil { - return fmt.Errorf("B2 client not initialized") - } - - // Load environment variables for B2 credentials - keyID := os.Getenv("B2_KEY_ID") - applicationKey := os.Getenv("B2_APPLICATION_KEY") - if keyID == "" || applicationKey == "" { - return fmt.Errorf("B2_KEY_ID and B2_APPLICATION_KEY must be set in .env file") - } - - // Get stored password for this service - password, err := r.getStoredPassword(serviceName) - if err != nil { - return fmt.Errorf("failed to get stored password: %w", err) - } - - // Generate bucket name from service name - bucketName := fmt.Sprintf("backea-%s", strings.ToLower(serviceName)) - - // Check if bucket exists - _, err = r.B2Client.GetBucket(ctx, bucketName) - if err != nil { - return fmt.Errorf("bucket %s not found: %w", bucketName, err) - } - - // Check if kopia repository is already connected - cmd := exec.Command("kopia", "repository", "status") - err = cmd.Run() - if err == nil { - // Already connected - log.Printf("Already connected to a repository, disconnecting first") - disconnectCmd := exec.Command("kopia", "repository", "disconnect") - if err := disconnectCmd.Run(); err != nil { - return fmt.Errorf("failed to disconnect from current repository: %w", err) - } - } - - // Connect to the repository - log.Printf("Connecting to B2 repository for %s", serviceName) - connectCmd := exec.Command( - "kopia", "repository", "connect", "b2", - "--bucket", bucketName, - "--key-id", keyID, - "--key", applicationKey, - "--password", password, - ) - connectOutput, err := connectCmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to connect to repository: %w\nOutput: %s", err, connectOutput) - } - - log.Printf("Successfully connected to repository for %s", serviceName) - return nil -} - -// ListSnapshots lists all snapshots in the repository for a given service -func (r *RestoreManager) ListSnapshots(ctx context.Context, serviceName string) error { - // Connect to the repository - if err := r.connectToRepository(ctx, serviceName); err != nil { - return err - } - - // List all snapshots - log.Printf("Listing snapshots for %s", serviceName) - listCmd := exec.Command("kopia", "snapshot", "list") - listOutput, err := listCmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to list snapshots: %w\nOutput: %s", err, listOutput) - } - - // Print the output - fmt.Println("Available snapshots:") - fmt.Println(string(listOutput)) - - return nil -} - -// RestoreFile restores a file or directory from a specific snapshot to a target location -func (r *RestoreManager) RestoreFile(ctx context.Context, serviceName, snapshotID, sourcePath, targetPath string) error { - // Connect to the repository - if err := r.connectToRepository(ctx, serviceName); err != nil { - return err - } - - // Create a subdirectory with the snapshot ID name - snapshotDirPath := filepath.Join(targetPath, snapshotID) - if err := os.MkdirAll(snapshotDirPath, 0755); err != nil { - return fmt.Errorf("failed to create target directory: %w", err) - } - - // Construct the kopia restore command - log.Printf("Restoring from snapshot %s to %s", snapshotID, snapshotDirPath) - - // Build the command with all required parameters - restoreCmd := exec.Command( - "kopia", "snapshot", "restore", - snapshotID, // Just the snapshot ID - snapshotDirPath, // Target location where files will be restored - ) - - // Execute the command and capture output - restoreOutput, err := restoreCmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to restore files: %w\nOutput: %s", err, restoreOutput) - } - - log.Printf("Successfully restored files to %s", snapshotDirPath) - log.Printf("Restore output: %s", string(restoreOutput)) - - return nil -} diff --git a/internal/backup/security/password.go b/internal/backup/security/password.go new file mode 100644 index 0000000..a753015 --- /dev/null +++ b/internal/backup/security/password.go @@ -0,0 +1,115 @@ +package security + +import ( + "bufio" + "crypto/rand" + "fmt" + "log" + "os" + "strings" +) + +// GetOrCreatePassword retrieves a password from kopia.env or creates a new one if it doesn't exist +func GetOrCreatePassword(serviceName string, length int) (string, error) { + // Define the expected key in the env file + // Replace dots with underscores for environment variable name + safeServiceName := strings.ReplaceAll(serviceName, ".", "_") + passwordKey := fmt.Sprintf("KOPIA_%s_PASSWORD", strings.ToUpper(safeServiceName)) + + // Try to read from kopia.env first + kopiaEnvPath := "kopia.env" + if _, err := os.Stat(kopiaEnvPath); err == nil { + // File exists, check if the password is already there + file, err := os.Open(kopiaEnvPath) + if err != nil { + return "", fmt.Errorf("failed to open kopia.env: %w", err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, passwordKey+"=") { + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + // Found the password, remove quotes if present + password := parts[1] + password = strings.Trim(password, "\"") + return password, nil + } + } + } + + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("error reading kopia.env: %w", err) + } + } + + // Password not found or file doesn't exist, generate a new password + password, err := GenerateSecurePassword(length) + if err != nil { + return "", fmt.Errorf("failed to generate password: %w", err) + } + + // Create or append to kopia.env + file, err := os.OpenFile(kopiaEnvPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return "", fmt.Errorf("failed to open kopia.env for writing: %w", err) + } + defer file.Close() + + // Write the new password entry with quotes + passwordEntry := fmt.Sprintf("%s=\"%s\"\n", passwordKey, password) + if _, err := file.WriteString(passwordEntry); err != nil { + return "", fmt.Errorf("failed to write to kopia.env: %w", err) + } + + log.Printf("Created new password for service %s and stored in kopia.env", serviceName) + return password, nil +} + +// GenerateSecurePassword creates a cryptographically secure random password +// using only safe characters that work well with command line tools +func GenerateSecurePassword(length int) (string, error) { + // Use a more robust but safe character set + // Avoiding characters that might cause shell interpretation issues + // No quotes, backslashes, spaces, or common special chars that need escaping + const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + result := make([]byte, length) + + // Create a secure source of randomness + randomBytes := make([]byte, length) + if _, err := rand.Read(randomBytes); err != nil { + return "", err + } + + // Ensure minimum complexity requirements + // At least 2 uppercase, 2 lowercase, 2 digits + if length >= 6 { + // Force first characters to meet minimum requirements + result[0] = 'A' + byte(randomBytes[0]%26) // Uppercase + result[1] = 'A' + byte(randomBytes[1]%26) // Uppercase + result[2] = 'a' + byte(randomBytes[2]%26) // Lowercase + result[3] = 'a' + byte(randomBytes[3]%26) // Lowercase + result[4] = '0' + byte(randomBytes[4]%10) // Digit + result[5] = '0' + byte(randomBytes[5]%10) // Digit + + // Fill the rest with random chars + for i := 6; i < length; i++ { + result[i] = chars[int(randomBytes[i])%len(chars)] + } + + // Shuffle the result to avoid predictable pattern + for i := length - 1; i > 0; i-- { + j := int(randomBytes[i]) % (i + 1) + result[i], result[j] = result[j], result[i] + } + } else { + // For very short passwords just use random chars + for i := 0; i < length; i++ { + result[i] = chars[int(randomBytes[i])%len(chars)] + } + } + + return string(result), nil +} diff --git a/internal/backup/strategy.go b/internal/backup/strategy.go deleted file mode 100644 index dd0d64f..0000000 --- a/internal/backup/strategy.go +++ /dev/null @@ -1,57 +0,0 @@ -package backup - -import ( - "context" - "io" - "time" -) - -// Strategy defines the backup/restore strategy interface -type Strategy interface { - // Execute performs a backup - Execute(ctx context.Context, serviceName, directory string) error - - ListBackups(ctx context.Context, serviceName string) ([]BackupInfo, error) - - GetStorageUsage(ctx context.Context, serviceName string) (*StorageUsageInfo, error) - - RestoreBackup(ctx context.Context, backupID string, serviceName string) error - - DownloadBackup(ctx context.Context, backupID, serviceName string) (io.ReadCloser, error) - - GetBackupInfo(context.Context, string, string) (*BackupInfo, error) -} - -// BackupInfo contains information about a single backup -type BackupInfo struct { - ID string // Unique identifier for the backup - CreationTime time.Time // When the backup was created - Size int64 // Size in bytes - Source string // Original source path - Type string // Type of backup (e.g., "kopia", "restic") - RetentionTag string // Retention policy applied (e.g., "latest", "daily", "weekly") -} - -// StorageUsageInfo contains information about backup storage usage -type StorageUsageInfo struct { - TotalBytes int64 // Total bytes stored - Provider string // Storage provider (e.g., "local", "b2", "s3") - ProviderID string // Provider-specific ID (bucket name, path, etc.) -} - -// Retention represents retention policy for backups -type Retention struct { - KeepLatest int - KeepHourly int - KeepDaily int - KeepWeekly int - KeepMonthly int - KeepYearly int -} - -// Destination represents rsync destination -type Destination struct { - Host string - Path string - SSHKey string -} diff --git a/internal/backup/strategy/factory.go b/internal/backup/strategy/factory.go new file mode 100644 index 0000000..84d640a --- /dev/null +++ b/internal/backup/strategy/factory.go @@ -0,0 +1,150 @@ +package strategy + +import ( + "backea/internal/backup/config" + "backea/internal/backup/strategy/kopia" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// Factory handles backup strategy creation +type Factory struct { + Config *config.Configuration + ConfigPath string +} + +// NewFactory initializes and returns a Factory +func NewFactory(configPath string) (*Factory, error) { + cfg, err := config.LoadConfig(configPath) + if err != nil { + return nil, fmt.Errorf("failed to load config: %w", err) + } + return &Factory{Config: cfg, ConfigPath: configPath}, nil +} + +// CreateBackupStrategyForService returns a backup strategy based on config +func (f *Factory) CreateBackupStrategyForService(groupName, serviceIndex string) (Strategy, error) { + serviceConfig, err := f.getServiceConfig(groupName, serviceIndex) + if err != nil { + return nil, err + } + sourcePath, err := f.getServicePath(groupName) + if err != nil { + return nil, err + } + + switch serviceConfig.BackupStrategy.Type { + case "kopia": + return f.createKopiaStrategy(serviceConfig, sourcePath) + default: + return nil, fmt.Errorf("unknown strategy type: %s", serviceConfig.BackupStrategy.Type) + } +} + +// createKopiaStrategy initializes a Kopia strategy +func (f *Factory) createKopiaStrategy(serviceConfig *config.BackupConfig, sourcePath string) (Strategy, error) { + provider, err := f.createKopiaProvider(serviceConfig.BackupStrategy) + if err != nil { + return nil, fmt.Errorf("failed to create kopia provider: %w", err) + } + + retention := kopia.Retention(serviceConfig.BackupStrategy.Retention) + configPath := generateTempConfigPath(provider.GetProviderType()) + + return kopia.NewStrategy(retention, provider, configPath, sourcePath), nil +} + +// createKopiaProvider returns a Kopia storage provider +func (f *Factory) createKopiaProvider(strategyConfig config.StrategyConfig) (kopia.Provider, error) { + switch strategyConfig.Provider { + case "local": + return kopia.NewLocalProvider(getDefaultPath(strategyConfig.Destination.Path)), nil + case "b2", "backblaze": + return kopia.NewB2Provider(), nil + // case "sftp": + // return createSFTPProvider(strategyConfig.Destination) + default: + return nil, fmt.Errorf("unknown kopia provider type: %s", strategyConfig.Provider) + } +} + +// createSFTPProvider handles SFTP provider creation +func createSFTPProvider(dest DestinationConfig) (kopia.Provider, error) { + host, username := parseSFTPHost(dest.Host) + if host == "" { + return nil, fmt.Errorf("SFTP host not specified") + } + return kopia.NewSFTPProvider(host, getDefaultPath(dest.Path), username, getSSHKeyPath(dest.SSHKey)), nil +} + +// parseSFTPHost extracts the username and host from "user@host" +func parseSFTPHost(host string) (string, string) { + if atIndex := strings.Index(host, "@"); atIndex > 0 { + return host[atIndex+1:], host[:atIndex] + } + return host, getEnvUser() +} + +// getDefaultPath returns the given path or a default +func getDefaultPath(path string) string { + if path != "" { + return path + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".backea", "repos") +} + +// getEnvUser returns the system username +func getEnvUser() string { + if user := os.Getenv("USER"); user != "" { + return user + } + return "root" +} + +// getSSHKeyPath returns the SSH key path +func getSSHKeyPath(key string) string { + if key != "" { + return key + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".ssh", "id_rsa") +} + +// getServicePath returns a service's path +func (f *Factory) getServicePath(groupName string) (string, error) { + if serviceGroup, exists := f.Config.Services[groupName]; exists { + return serviceGroup.Source.Path, nil + } + return "", fmt.Errorf("service group not found: %s", groupName) +} + +// getServiceConfig returns a service's config +func (f *Factory) getServiceConfig(groupName, serviceIndex string) (*config.BackupConfig, error) { + if serviceGroup, exists := f.Config.Services[groupName]; exists { + if backupConfig, exists := serviceGroup.BackupConfigs[serviceIndex]; exists { + return &backupConfig, nil + } + } + return nil, fmt.Errorf("backup config not found: %s.%s", groupName, serviceIndex) +} + +// ReloadConfig reloads configuration +func (f *Factory) ReloadConfig() error { + cfg, err := config.LoadConfig(f.ConfigPath) + if err != nil { + return fmt.Errorf("could not reload config: %w", err) + } + f.Config = cfg + return nil +} + +// generateTempConfigPath creates a temp Kopia config path +func generateTempConfigPath(providerType string) string { + tmpConfigDir := filepath.Join(os.TempDir(), "kopia_configs") + _ = os.MkdirAll(tmpConfigDir, 0755) // Ensure directory exists + return filepath.Join(tmpConfigDir, fmt.Sprintf("kopia_%s_%d.config", providerType, time.Now().UnixNano())) +} diff --git a/internal/backup/strategy/kopia/b2.go b/internal/backup/strategy/kopia/b2.go new file mode 100644 index 0000000..fb44a28 --- /dev/null +++ b/internal/backup/strategy/kopia/b2.go @@ -0,0 +1,113 @@ +package kopia + +import ( + "context" + "fmt" + "log" + "os" + "os/exec" + "strings" + + b2_client "backea/internal/client" +) + +// B2Provider implements the Provider interface for Backblaze B2 +type B2Provider struct{} + +// NewB2Provider creates a new B2 provider +func NewB2Provider() *B2Provider { + return &B2Provider{} +} + +// Connect connects to a B2 repository +func (p *B2Provider) Connect(ctx context.Context, serviceName string, password string, configPath string) error { + // Load environment variables for B2 credentials + keyID := os.Getenv("B2_KEY_ID") + applicationKey := os.Getenv("B2_APPLICATION_KEY") + if keyID == "" || applicationKey == "" { + return fmt.Errorf("B2_KEY_ID and B2_APPLICATION_KEY must be set in .env file") + } + + bucketName := p.GetBucketName(serviceName) + + // Create B2 client and check if bucket exists, create if not + B2Client, _ := b2_client.NewClientFromEnv() + if B2Client == nil { + return fmt.Errorf("B2 client not initialized") + } + + _, err := B2Client.GetBucket(ctx, bucketName) + if err != nil { + log.Printf("Bucket %s not found, creating...", bucketName) + _, err = B2Client.CreateBucket(ctx, bucketName, false) + if err != nil { + return fmt.Errorf("failed to create bucket: %w", err) + } + log.Printf("Created bucket: %s", bucketName) + } + + // Try to connect to existing repository with config file + connectCmd := exec.CommandContext( + ctx, + "kopia", + "--config-file", configPath, + "repository", "connect", "b2", + "--bucket", bucketName, + "--key-id", keyID, + "--key", applicationKey, + "--password", password, + ) + err = connectCmd.Run() + if err != nil { + // Connection failed, create new repository + log.Printf("Creating new B2 repository for %s", serviceName) + createCmd := exec.CommandContext( + ctx, + "kopia", + "--config-file", configPath, + "repository", "create", "b2", + "--bucket", bucketName, + "--key-id", keyID, + "--key", applicationKey, + "--password", password, + ) + createOutput, err := createCmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to create repository: %w\nOutput: %s", err, createOutput) + } + } + + return nil +} + +// GetRepositoryParams returns parameters for B2 operations +func (p *B2Provider) GetRepositoryParams(serviceName string) ([]string, error) { + keyID := os.Getenv("B2_KEY_ID") + applicationKey := os.Getenv("B2_APPLICATION_KEY") + if keyID == "" || applicationKey == "" { + return nil, fmt.Errorf("B2_KEY_ID and B2_APPLICATION_KEY must be set in .env file") + } + + bucketName := p.GetBucketName(serviceName) + + return []string{ + "b2", + "--bucket", bucketName, + "--key-id", keyID, + "--key", applicationKey, + }, nil +} + +// GetBucketName returns a bucket name for the service +func (p *B2Provider) GetBucketName(serviceName string) string { + // Backblaze doesn't allow dots in bucket names + sanitized := strings.ReplaceAll(serviceName, ".", "-") + + // Add a prefix to make the bucket name unique + return fmt.Sprintf("backea-%s", sanitized) +} + +// GetProviderType returns the provider type identifier +func (p *B2Provider) GetProviderType() string { + return "b2" +} diff --git a/internal/backup/strategy/kopia/local.go b/internal/backup/strategy/kopia/local.go new file mode 100644 index 0000000..98ff168 --- /dev/null +++ b/internal/backup/strategy/kopia/local.go @@ -0,0 +1,85 @@ +package kopia + +import ( + "context" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" +) + +// LocalProvider implements the Provider interface for local storage +type LocalProvider struct { + BasePath string +} + +// NewLocalProvider creates a new local provider +func NewLocalProvider(basePath string) *LocalProvider { + // If basePath is empty, use a default location + if basePath == "" { + basePath = filepath.Join(os.Getenv("HOME"), ".backea", "repos") + } + return &LocalProvider{ + BasePath: basePath, + } +} + +// Connect connects to a local repository +func (p *LocalProvider) Connect(ctx context.Context, serviceName string, password string, configPath string) error { + repoPath := filepath.Join(p.BasePath, serviceName) + log.Printf("Connecting to local repository at %s with config: %s", repoPath, configPath) + + // Ensure the directory exists + if err := os.MkdirAll(repoPath, 0755); err != nil { + return fmt.Errorf("failed to create repository directory: %w", err) + } + + // Try to connect to existing repository with config file + connectCmd := exec.CommandContext( + ctx, + "kopia", + "--config-file", configPath, + "repository", "connect", "filesystem", + "--path", repoPath, + "--password", password, + ) + err := connectCmd.Run() + if err != nil { + // Connection failed, create new repository + log.Printf("Creating new local repository for %s at destination: %s", serviceName, repoPath) + createCmd := exec.CommandContext( + ctx, + "kopia", + "--config-file", configPath, + "repository", "create", "filesystem", + "--path", repoPath, + "--password", password, + ) + createOutput, err := createCmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to create repository: %w\nOutput: %s", err, createOutput) + } + } + + return nil +} + +// GetRepositoryParams returns parameters for local filesystem operations +func (p *LocalProvider) GetRepositoryParams(serviceName string) ([]string, error) { + repoPath := filepath.Join(p.BasePath, serviceName) + return []string{ + "filesystem", + "--path", repoPath, + }, nil +} + +// GetBucketName returns the path for a service +func (p *LocalProvider) GetBucketName(serviceName string) string { + return filepath.Join(p.BasePath, serviceName) +} + +// GetProviderType returns the provider type identifier +func (p *LocalProvider) GetProviderType() string { + return "local" +} diff --git a/internal/backup/strategy/kopia/providers.go b/internal/backup/strategy/kopia/providers.go new file mode 100644 index 0000000..af072d4 --- /dev/null +++ b/internal/backup/strategy/kopia/providers.go @@ -0,0 +1,20 @@ +package kopia + +import ( + "context" +) + +// Provider defines the interface for different Kopia storage backends +type Provider interface { + // Connect connects to an existing repository or creates a new one + Connect(ctx context.Context, serviceName string, password string, configPath string) error + + // GetRepositoryParams returns the parameters needed for repository operations + GetRepositoryParams(serviceName string) ([]string, error) + + // GetBucketName returns the storage identifier (bucket name or path) + GetBucketName(serviceName string) string + + // GetProviderType returns a string identifying the provider type + GetProviderType() string +} diff --git a/internal/backup/strategy/kopia/sftp.go b/internal/backup/strategy/kopia/sftp.go new file mode 100644 index 0000000..26be234 --- /dev/null +++ b/internal/backup/strategy/kopia/sftp.go @@ -0,0 +1,87 @@ +package kopia + +import ( + "context" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" +) + +// SFTPProvider implements the Provider interface for SFTP remote storage +type SFTPProvider struct { + Host string + BasePath string + Username string + KeyFile string +} + +// NewSFTPProvider creates a new SFTP provider +func NewSFTPProvider(host, basePath, username, keyFile string) *SFTPProvider { + return &SFTPProvider{ + Host: host, + BasePath: basePath, + Username: username, + KeyFile: keyFile, + } +} + +// Connect connects to an SFTP repository +func (p *SFTPProvider) Connect(ctx context.Context, serviceName string, password string, configPath string) error { + repoPath := fmt.Sprintf("%s@%s:%s/%s", p.Username, p.Host, p.BasePath, serviceName) + + // Try to connect to existing repository with config file + connectCmd := exec.CommandContext( + ctx, + "kopia", + "--config-file", configPath, + "repository", "connect", "sftp", + "--path", repoPath, + "--keyfile", p.KeyFile, + "--known-hosts", filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts"), + "--password", password, + ) + err := connectCmd.Run() + if err != nil { + // Connection failed, create new repository + log.Printf("Creating new SFTP repository for %s at %s", serviceName, repoPath) + createCmd := exec.CommandContext( + ctx, + "kopia", + "--config-file", configPath, + "repository", "create", "sftp", + "--path", repoPath, + "--keyfile", p.KeyFile, + "--known-hosts", filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts"), + "--password", password, + ) + createOutput, err := createCmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to create repository: %w\nOutput: %s", err, createOutput) + } + } + + return nil +} + +// GetRepositoryParams returns parameters for SFTP operations +func (p *SFTPProvider) GetRepositoryParams(serviceName string) ([]string, error) { + repoPath := fmt.Sprintf("%s@%s:%s/%s", p.Username, p.Host, p.BasePath, serviceName) + return []string{ + "sftp", + "--path", repoPath, + "--keyfile", p.KeyFile, + "--known-hosts", filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts"), + }, nil +} + +// GetBucketName returns the path for a service +func (p *SFTPProvider) GetBucketName(serviceName string) string { + return fmt.Sprintf("%s/%s", p.BasePath, serviceName) +} + +// GetProviderType returns the provider type identifier +func (p *SFTPProvider) GetProviderType() string { + return "sftp" +} diff --git a/internal/backup/backup_kopia.go b/internal/backup/strategy/kopia/strategy.go similarity index 67% rename from internal/backup/backup_kopia.go rename to internal/backup/strategy/kopia/strategy.go index c3b4e66..87e2013 100644 --- a/internal/backup/backup_kopia.go +++ b/internal/backup/strategy/kopia/strategy.go @@ -1,10 +1,8 @@ -package backup +package kopia import ( "archive/zip" - "bufio" "context" - "crypto/rand" "encoding/json" "fmt" "io" @@ -15,30 +13,45 @@ import ( "strconv" "strings" "time" + + "backea/internal/backup/models" + "backea/internal/backup/security" ) -// KopiaStrategy implements the backup strategy using Kopia -type KopiaStrategy struct { +// Strategy implements the backup strategy using Kopia +type Strategy struct { Retention Retention - Provider KopiaProvider + Provider Provider ConfigPath string + SourcePath string } -// NewKopiaStrategy creates a new kopia strategy with specified provider -func NewKopiaStrategy(retention Retention, provider KopiaProvider, configPath string) *KopiaStrategy { - return &KopiaStrategy{ +// Retention represents retention policy for backups +type Retention struct { + KeepLatest int + KeepHourly int + KeepDaily int + KeepWeekly int + KeepMonthly int + KeepYearly int +} + +// NewStrategy creates a new kopia strategy with specified provider +func NewStrategy(retention Retention, provider Provider, configPath string, sourcePath string) *Strategy { + return &Strategy{ Retention: retention, Provider: provider, ConfigPath: configPath, + SourcePath: sourcePath, } } // Execute performs the kopia backup -func (s *KopiaStrategy) Execute(ctx context.Context, serviceName, directory string) error { +func (s *Strategy) Execute(ctx context.Context, serviceName, directory string) error { log.Printf("Performing kopia backup for service %s: %s", serviceName, directory) // Get or create password for this service - password, err := getOrCreatePassword(serviceName, 48) + password, err := security.GetOrCreatePassword(serviceName, 48) if err != nil { return fmt.Errorf("failed to get or create password: %w", err) } @@ -87,27 +100,21 @@ func (s *KopiaStrategy) Execute(ctx context.Context, serviceName, directory stri } // ListBackups returns information about existing backups -func (s *KopiaStrategy) ListBackups(ctx context.Context, serviceName string) ([]BackupInfo, error) { - // Parse service group and index from service name (e.g., "backealocal.1") - groupName, _, err := parseServiceName(serviceName) +func (s *Strategy) ListBackups(ctx context.Context, serviceName string) ([]models.BackupInfo, error) { + // Parse service group and index from service name + _, _, err := parseServiceName(serviceName) if err != nil { return nil, fmt.Errorf("invalid service name format: %w", err) } - // Get service directory from config - factory, err := NewBackupFactory("config.yml") - if err != nil { - return nil, fmt.Errorf("failed to create factory: %w", err) + // Use the source path from the Strategy instead of trying to get it from the factory + directoryPath := s.SourcePath + if directoryPath == "" { + return nil, fmt.Errorf("source path not specified") } - // Find service group - serviceGroup, exists := factory.Config.Services[groupName] - if !exists { - return nil, fmt.Errorf("service group not found: %s", groupName) - } - - // Get directory from the service group level - directoryPath := strings.TrimRight(serviceGroup.Source.Path, "/") + // Trim trailing slash if any + directoryPath = strings.TrimRight(directoryPath, "/") // Ensure we're connected to the repository err = s.EnsureRepositoryConnected(ctx, serviceName) @@ -137,7 +144,7 @@ func (s *KopiaStrategy) ListBackups(ctx context.Context, serviceName string) ([] } // Convert to BackupInfo - var result []BackupInfo + var result []models.BackupInfo for _, snap := range snapshots { // Get basic info id, _ := snap["id"].(string) @@ -184,7 +191,7 @@ func (s *KopiaStrategy) ListBackups(ctx context.Context, serviceName string) ([] } } - result = append(result, BackupInfo{ + result = append(result, models.BackupInfo{ ID: id, CreationTime: creationTime, Size: size, @@ -198,7 +205,7 @@ func (s *KopiaStrategy) ListBackups(ctx context.Context, serviceName string) ([] } // GetStorageUsage returns information about the total storage used by the repository -func (s *KopiaStrategy) GetStorageUsage(ctx context.Context, serviceName string) (*StorageUsageInfo, error) { +func (s *Strategy) GetStorageUsage(ctx context.Context, serviceName string) (*models.StorageUsageInfo, error) { // Ensure we're connected to the repository err := s.EnsureRepositoryConnected(ctx, serviceName) if err != nil { @@ -207,17 +214,10 @@ func (s *KopiaStrategy) GetStorageUsage(ctx context.Context, serviceName string) // Get provider type (b2, local, sftp) providerType := "unknown" - switch s.Provider.(type) { - case *KopiaB2Provider: - providerType = "b2" - case *KopiaLocalProvider: - providerType = "local" - case *KopiaSFTPProvider: - providerType = "sftp" - } + providerType = s.Provider.GetProviderType() // Initialize storage info - info := &StorageUsageInfo{ + info := &models.StorageUsageInfo{ Provider: providerType, ProviderID: s.Provider.GetBucketName(serviceName), } @@ -273,195 +273,18 @@ func (s *KopiaStrategy) GetStorageUsage(ctx context.Context, serviceName string) return info, nil } -// Helper method to ensure the repository is connected before operations -func (s *KopiaStrategy) EnsureRepositoryConnected(ctx context.Context, serviceName string) error { - // Check if kopia repository is already connected with config file - cmd := exec.Command("kopia", "--config-file", s.ConfigPath, "repository", "status") - err := cmd.Run() - if err != nil { - // Repository not connected, try to connect - password, err := getOrCreatePassword(serviceName, 48) - if err != nil { - return fmt.Errorf("failed to get password: %w", err) - } - - // Connect using provider - if err := s.Provider.Connect(ctx, serviceName, password, s.ConfigPath); err != nil { - return fmt.Errorf("failed to connect to repository: %w", err) - } - } - - return nil -} - -// parseServiceName splits a service name in the format "group.index" into its components -func parseServiceName(serviceName string) (string, string, error) { - parts := strings.SplitN(serviceName, ".", 2) - if len(parts) != 2 { - return "", "", fmt.Errorf("service name must be in format 'group.index', got '%s'", serviceName) - } - return parts[0], parts[1], nil -} - -// getOrCreatePassword retrieves a password from kopia.env or creates a new one if it doesn't exist -// Extracted to a package-level function since it's utility functionality -func getOrCreatePassword(serviceName string, length int) (string, error) { - // Define the expected key in the env file - // Replace dots with underscores for environment variable name - safeServiceName := strings.ReplaceAll(serviceName, ".", "_") - passwordKey := fmt.Sprintf("KOPIA_%s_PASSWORD", strings.ToUpper(safeServiceName)) - - // Try to read from kopia.env first - kopiaEnvPath := "kopia.env" - if _, err := os.Stat(kopiaEnvPath); err == nil { - // File exists, check if the password is already there - file, err := os.Open(kopiaEnvPath) - if err != nil { - return "", fmt.Errorf("failed to open kopia.env: %w", err) - } - defer file.Close() - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := scanner.Text() - if strings.HasPrefix(line, passwordKey+"=") { - parts := strings.SplitN(line, "=", 2) - if len(parts) == 2 { - // Found the password, remove quotes if present - password := parts[1] - password = strings.Trim(password, "\"") - return password, nil - } - } - } - - if err := scanner.Err(); err != nil { - return "", fmt.Errorf("error reading kopia.env: %w", err) - } - } - - // Password not found or file doesn't exist, generate a new password - password, err := generateSecurePassword(length) - if err != nil { - return "", fmt.Errorf("failed to generate password: %w", err) - } - - // Create or append to kopia.env - file, err := os.OpenFile(kopiaEnvPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) - if err != nil { - return "", fmt.Errorf("failed to open kopia.env for writing: %w", err) - } - defer file.Close() - - // Write the new password entry with quotes - passwordEntry := fmt.Sprintf("%s=\"%s\"\n", passwordKey, password) - if _, err := file.WriteString(passwordEntry); err != nil { - return "", fmt.Errorf("failed to write to kopia.env: %w", err) - } - - log.Printf("Created new password for service %s and stored in kopia.env", serviceName) - return password, nil -} - -// generateSecurePassword creates a cryptographically secure random password -// using only safe characters that work well with command line tools -func generateSecurePassword(length int) (string, error) { - // Use a more robust but safe character set - // Avoiding characters that might cause shell interpretation issues - // No quotes, backslashes, spaces, or common special chars that need escaping - const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" - result := make([]byte, length) - - // Create a secure source of randomness - randomBytes := make([]byte, length) - if _, err := rand.Read(randomBytes); err != nil { - return "", err - } - - // Ensure minimum complexity requirements - // At least 2 uppercase, 2 lowercase, 2 digits - if length >= 6 { - // Force first characters to meet minimum requirements - result[0] = 'A' + byte(randomBytes[0]%26) // Uppercase - result[1] = 'A' + byte(randomBytes[1]%26) // Uppercase - result[2] = 'a' + byte(randomBytes[2]%26) // Lowercase - result[3] = 'a' + byte(randomBytes[3]%26) // Lowercase - result[4] = '0' + byte(randomBytes[4]%10) // Digit - result[5] = '0' + byte(randomBytes[5]%10) // Digit - - // Fill the rest with random chars - for i := 6; i < length; i++ { - result[i] = chars[int(randomBytes[i])%len(chars)] - } - - // Shuffle the result to avoid predictable pattern - for i := length - 1; i > 0; i-- { - j := int(randomBytes[i]) % (i + 1) - result[i], result[j] = result[j], result[i] - } - } else { - // For very short passwords just use random chars - for i := 0; i < length; i++ { - result[i] = chars[int(randomBytes[i])%len(chars)] - } - } - - return string(result), nil -} - -// parseHumanSize parses a human-readable size string (e.g., "32.8 MB") into bytes -func parseHumanSize(sizeStr string) (int64, error) { - parts := strings.Fields(sizeStr) - if len(parts) != 2 { - return 0, fmt.Errorf("invalid size format: %s", sizeStr) - } - - value, err := strconv.ParseFloat(parts[0], 64) - if err != nil { - return 0, fmt.Errorf("invalid size value: %w", err) - } - - unit := strings.ToUpper(parts[1]) - switch unit { - case "B": - return int64(value), nil - case "KB", "KIB": - return int64(value * 1024), nil - case "MB", "MIB": - return int64(value * 1024 * 1024), nil - case "GB", "GIB": - return int64(value * 1024 * 1024 * 1024), nil - case "TB", "TIB": - return int64(value * 1024 * 1024 * 1024 * 1024), nil - default: - return 0, fmt.Errorf("unknown size unit: %s", unit) - } -} - // RestoreBackup restores a backup with the given ID -func (s *KopiaStrategy) RestoreBackup(ctx context.Context, backupID string, serviceName string) error { +func (s *Strategy) RestoreBackup(ctx context.Context, backupID string, serviceName string) error { // Ensure repository is connected err := s.EnsureRepositoryConnected(ctx, serviceName) if err != nil { return fmt.Errorf("failed to connect to repository: %w", err) } - // Parse service group and index from service name - groupName, _, err := parseServiceName(serviceName) - if err != nil { - return fmt.Errorf("invalid service name format: %w", err) - } - - // Get service directory from config - factory, err := NewBackupFactory("config.yml") - if err != nil { - return fmt.Errorf("failed to create factory: %w", err) - } - - // Find service group - serviceGroup, exists := factory.Config.Services[groupName] - if !exists { - return fmt.Errorf("service group not found: %s", groupName) + // Get source directory from strategy instead of importing factory + targetDir := s.SourcePath + if targetDir == "" { + return fmt.Errorf("source path not specified") } // Create a temporary directory for restore @@ -489,7 +312,6 @@ func (s *KopiaStrategy) RestoreBackup(ctx context.Context, backupID string, serv } // Now we need to sync the restored data to the original directory - targetDir := serviceGroup.Source.Path log.Printf("Syncing restored data from %s to %s", restoreDir, targetDir) // Use rsync for the final transfer to avoid permissions issues @@ -519,8 +341,7 @@ func (s *KopiaStrategy) RestoreBackup(ctx context.Context, backupID string, serv } // DownloadBackup provides a reader with backup summary information in text format -func (s *KopiaStrategy) DownloadBackup(ctx context.Context, backupID string, serviceName string) (io.ReadCloser, error) { - +func (s *Strategy) DownloadBackup(ctx context.Context, backupID string, serviceName string) (io.ReadCloser, error) { // Ensure repository is connected err := s.EnsureRepositoryConnected(ctx, serviceName) if err != nil { @@ -647,8 +468,7 @@ func (s *KopiaStrategy) DownloadBackup(ctx context.Context, backupID string, ser } // GetBackupInfo returns detailed information about a specific backup -func (s *KopiaStrategy) GetBackupInfo(ctx context.Context, backupID string, serviceName string) (*BackupInfo, error) { - +func (s *Strategy) GetBackupInfo(ctx context.Context, backupID string, serviceName string) (*models.BackupInfo, error) { err := s.EnsureRepositoryConnected(ctx, serviceName) if err != nil { return nil, fmt.Errorf("failed to connect to repository: %w", err) @@ -717,7 +537,7 @@ func (s *KopiaStrategy) GetBackupInfo(ctx context.Context, backupID string, serv } } - return &BackupInfo{ + return &models.BackupInfo{ ID: id, CreationTime: creationTime, Size: size, @@ -727,6 +547,65 @@ func (s *KopiaStrategy) GetBackupInfo(ctx context.Context, backupID string, serv }, nil } +// Helper method to ensure the repository is connected before operations +func (s *Strategy) EnsureRepositoryConnected(ctx context.Context, serviceName string) error { + // Check if kopia repository is already connected with config file + cmd := exec.Command("kopia", "--config-file", s.ConfigPath, "repository", "status") + err := cmd.Run() + if err != nil { + // Repository not connected, try to connect + password, err := security.GetOrCreatePassword(serviceName, 48) + if err != nil { + return fmt.Errorf("failed to get password: %w", err) + } + + // Connect using provider + if err := s.Provider.Connect(ctx, serviceName, password, s.ConfigPath); err != nil { + return fmt.Errorf("failed to connect to repository: %w", err) + } + } + + return nil +} + +// parseServiceName splits a service name in the format "group.index" into its components +func parseServiceName(serviceName string) (string, string, error) { + parts := strings.SplitN(serviceName, ".", 2) + if len(parts) != 2 { + return "", "", fmt.Errorf("service name must be in format 'group.index', got '%s'", serviceName) + } + return parts[0], parts[1], nil +} + +// parseHumanSize parses a human-readable size string (e.g., "32.8 MB") into bytes +func parseHumanSize(sizeStr string) (int64, error) { + parts := strings.Fields(sizeStr) + if len(parts) != 2 { + return 0, fmt.Errorf("invalid size format: %s", sizeStr) + } + + value, err := strconv.ParseFloat(parts[0], 64) + if err != nil { + return 0, fmt.Errorf("invalid size value: %w", err) + } + + unit := strings.ToUpper(parts[1]) + switch unit { + case "B": + return int64(value), nil + case "KB", "KIB": + return int64(value * 1024), nil + case "MB", "MIB": + return int64(value * 1024 * 1024), nil + case "GB", "GIB": + return int64(value * 1024 * 1024 * 1024), nil + case "TB", "TIB": + return int64(value * 1024 * 1024 * 1024 * 1024), nil + default: + return 0, fmt.Errorf("unknown size unit: %s", unit) + } +} + // cleanupReadCloser is a wrapper around io.ReadCloser that performs cleanup when closed type cleanupReadCloser struct { io.ReadCloser @@ -739,29 +618,3 @@ func (c *cleanupReadCloser) Close() error { c.cleanup() return err } - -// extractServiceNameFromBackupID extracts the service name from a backup ID -// This is an approximation as the exact format depends on your ID structure -func extractServiceNameFromBackupID(backupID string) string { - // Kopia snapshot IDs don't directly include the service name - // You may need to adjust this based on your actual ID format - - // Try to extract the pattern from your backups list - // If you're using ListBackups to find all backups, you might have - // a mapping of IDs to service names that you can use - - // For this example, let's assume we're using environment variables to track this - envVar := fmt.Sprintf("BACKEA_SNAPSHOT_%s", backupID) - if serviceName := os.Getenv(envVar); serviceName != "" { - return serviceName - } - - // Fallback: For testing, we'll return a default or parse from the first part - // This should be replaced with your actual logic - parts := strings.Split(backupID, "-") - if len(parts) > 0 { - return parts[0] - } - - return "unknown" -} diff --git a/internal/backup/strategy/types.go b/internal/backup/strategy/types.go new file mode 100644 index 0000000..622e24b --- /dev/null +++ b/internal/backup/strategy/types.go @@ -0,0 +1,85 @@ +package strategy + +import ( + "backea/internal/backup/models" + "context" + "io" + "time" +) + +// BackupInfo contains information about a single backup +type BackupInfo struct { + ID string // Unique identifier for the backup + CreationTime time.Time // When the backup was created + Size int64 // Size in bytes + Source string // Original source path + Type string // Type of backup (e.g., "kopia", "restic") + RetentionTag string // Retention policy applied (e.g., "latest", "daily", "weekly") +} + +// StorageUsageInfo contains information about backup storage usage +type StorageUsageInfo struct { + TotalBytes int64 // Total bytes stored + Provider string // Storage provider (e.g., "local", "b2", "s3") + ProviderID string // Provider-specific ID (bucket name, path, etc.) +} + +// Strategy defines the backup/restore strategy interface +type Strategy interface { + // Execute performs a backup + Execute(ctx context.Context, serviceName, directory string) error + + // ListBackups gets information about existing backups + ListBackups(ctx context.Context, serviceName string) ([]models.BackupInfo, error) + + // GetStorageUsage returns information about the total storage used + GetStorageUsage(ctx context.Context, serviceName string) (*models.StorageUsageInfo, error) + + // RestoreBackup restores a backup to its original location + RestoreBackup(ctx context.Context, backupID string, serviceName string) error + + // DownloadBackup provides a reader with backup data for download + DownloadBackup(ctx context.Context, backupID, serviceName string) (io.ReadCloser, error) + + // GetBackupInfo returns detailed information about a specific backup + GetBackupInfo(ctx context.Context, backupID string, serviceName string) (*models.BackupInfo, error) +} + +// ServiceConfig represents the configuration for a service's backup +type BackupConfig struct { + BackupStrategy StrategyConfig // Strategy to use for backups +} + +// StrategyConfig defines configuration for a backup strategy +type StrategyConfig struct { + Type string // Type of backup strategy (e.g., "kopia", "rsync") + Provider string // Storage provider (e.g., "local", "b2", "sftp") + Destination DestinationConfig // Destination configuration + Retention RetentionConfig // Retention policy +} + +// DestinationConfig defines backup destination settings +type DestinationConfig struct { + Host string // Host for remote destination + Path string // Path for storage + Username string // Username for authentication + SSHKey string // SSH key for authentication +} + +// RetentionConfig defines backup retention policy +type RetentionConfig struct { + KeepLatest int // Number of latest snapshots to keep + KeepHourly int // Number of hourly snapshots to keep + KeepDaily int // Number of daily snapshots to keep + KeepWeekly int // Number of weekly snapshots to keep + KeepMonthly int // Number of monthly snapshots to keep + KeepYearly int // Number of yearly snapshots to keep +} + +// Factory creates backup strategies +// type Factory interface { +// CreateBackupStrategyForService(groupName, serviceIndex string) (Strategy, error) +// GetServicePath(groupName string) (string, error) +// GetServiceConfig(groupName, serviceIndex string) (*BackupConfig, error) +// ReloadConfig() error +// } diff --git a/internal/server/server.go b/internal/server/server.go index 246749b..74c910f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,25 +1,21 @@ package server import ( - "backea/internal/backup" + "backea/internal/backup/strategy" "backea/internal/web/routes" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" ) -// New creates a new configured Echo server -func New(factory *backup.BackupFactory) *echo.Echo { +func New(factory strategy.Factory) *echo.Echo { e := echo.New() - // Add middleware e.Use(middleware.Logger()) e.Use(middleware.Recover()) - // Setup static file serving e.Static("/static", "static") - // Register routes routes.RegisterRoutes(e, factory) return e diff --git a/internal/web/handlers/backup_actions_handler.go b/internal/web/handlers/backup_actions_handler.go index 6480aea..f8a5773 100644 --- a/internal/web/handlers/backup_actions_handler.go +++ b/internal/web/handlers/backup_actions_handler.go @@ -1,7 +1,7 @@ package handlers import ( - "backea/internal/backup" + "backea/internal/backup/strategy" "context" "fmt" "io" @@ -18,11 +18,11 @@ import ( // BackupActionsHandler handles backup-related actions like restore and download type BackupActionsHandler struct { - backupFactory *backup.BackupFactory + backupFactory strategy.Factory } // NewBackupActionsHandler creates a new backup actions handler -func NewBackupActionsHandler(factory *backup.BackupFactory) *BackupActionsHandler { +func NewBackupActionsHandler(factory strategy.Factory) *BackupActionsHandler { return &BackupActionsHandler{ backupFactory: factory, } @@ -185,7 +185,7 @@ func (h *BackupActionsHandler) RestoreBackupForm(c echo.Context) error { } // restoreBackupToCustomDestination handles restoring a backup to a custom directory -func (h *BackupActionsHandler) restoreBackupToCustomDestination(ctx context.Context, strategy backup.Strategy, backupID, serviceName, destinationPath string) error { +func (h *BackupActionsHandler) restoreBackupToCustomDestination(ctx context.Context, strategy strategy.Strategy, backupID, serviceName, destinationPath string) error { // Validate destination path destinationPath = filepath.Clean(destinationPath) diff --git a/internal/web/handlers/homepage_handler.go b/internal/web/handlers/homepage_handler.go index 09f2d04..d07aff7 100644 --- a/internal/web/handlers/homepage_handler.go +++ b/internal/web/handlers/homepage_handler.go @@ -1,7 +1,8 @@ package handlers import ( - "backea/internal/backup" + "backea/internal/backup/config" + "backea/internal/backup/strategy" "backea/templates" "context" "fmt" @@ -16,10 +17,10 @@ import ( ) type HomepageHandler struct { - backupFactory *backup.BackupFactory + backupFactory strategy.Factory } -func NewHomepageHandler(factory *backup.BackupFactory) *HomepageHandler { +func NewHomepageHandler(factory strategy.Factory) *HomepageHandler { return &HomepageHandler{ backupFactory: factory, } @@ -28,14 +29,20 @@ func NewHomepageHandler(factory *backup.BackupFactory) *HomepageHandler { // Home handles the homepage request and displays latest backups by service func (h *HomepageHandler) Home(c echo.Context) error { // Create the data structures - serviceBackups := make(map[string]map[string][]backup.BackupInfo) + serviceBackups := make(map[string]map[string][]strategy.BackupInfo) serviceConfigs := make(map[string]map[string]templates.ServiceProviderInfo) groupDirectories := make(map[string]string) // Store directories by group name + // Get the configuration from the factory + factoryConfig, err := h.getConfig() + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to get configuration: %v", err)) + } + // Process each service group - for groupName, serviceGroup := range h.backupFactory.Config.Services { + for groupName, serviceGroup := range factoryConfig.Services { // Initialize maps for this group - serviceBackups[groupName] = make(map[string][]backup.BackupInfo) + serviceBackups[groupName] = make(map[string][]strategy.BackupInfo) serviceConfigs[groupName] = make(map[string]templates.ServiceProviderInfo) // Store the directory at the group level in a separate map @@ -60,7 +67,6 @@ func (h *HomepageHandler) Home(c echo.Context) error { sort.Strings(sortedGroupNames) // Render the template with sorted group names and directories - // We don't load actual backup data on initial page load, it will be loaded via HTMX component := templates.Home(serviceBackups, serviceConfigs, sortedGroupNames, groupDirectories) return component.Render(c.Request().Context(), c.Response().Writer) } @@ -69,14 +75,20 @@ func (h *HomepageHandler) Home(c echo.Context) error { func (h *HomepageHandler) ServiceGroupHeader(c echo.Context) error { groupName := c.Param("groupName") + // Get the configuration + factoryConfig, err := h.getConfig() + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to get configuration: %v", err)) + } + // Check if the service group exists - serviceGroup, exists := h.backupFactory.Config.Services[groupName] + serviceGroup, exists := factoryConfig.Services[groupName] if !exists { - return echo.NewHTTPError(404, "Service group not found") + return echo.NewHTTPError(http.StatusNotFound, "Service group not found") } // Create data structures - serviceBackups := make(map[string][]backup.BackupInfo) + serviceBackups := make(map[string][]strategy.BackupInfo) serviceConfigs := make(map[string]templates.ServiceProviderInfo) // Setup synchronization @@ -140,14 +152,20 @@ func (h *HomepageHandler) ServiceGroupHeader(c echo.Context) error { func (h *HomepageHandler) ServiceGroupBackups(c echo.Context) error { groupName := c.Param("groupName") + // Get the configuration + factoryConfig, err := h.getConfig() + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to get configuration: %v", err)) + } + // Check if the service group exists - serviceGroup, exists := h.backupFactory.Config.Services[groupName] + serviceGroup, exists := factoryConfig.Services[groupName] if !exists { - return echo.NewHTTPError(404, "Service group not found") + return echo.NewHTTPError(http.StatusNotFound, "Service group not found") } // Create data structures - serviceBackups := make(map[string][]backup.BackupInfo) + serviceBackups := make(map[string][]strategy.BackupInfo) serviceConfigs := make(map[string]templates.ServiceProviderInfo) // Setup synchronization @@ -198,7 +216,7 @@ func (h *HomepageHandler) ServiceGroupBackups(c echo.Context) error { wg.Wait() // Create a map with just the group for the template - groupServiceBackups := make(map[string]map[string][]backup.BackupInfo) + groupServiceBackups := make(map[string]map[string][]strategy.BackupInfo) groupServiceBackups[groupName] = serviceBackups // Create a map with just the group configs for the template @@ -214,14 +232,20 @@ func (h *HomepageHandler) ServiceGroupBackups(c echo.Context) error { func (h *HomepageHandler) ServiceGroupAllBackups(c echo.Context) error { groupName := c.Param("groupName") + // Get the configuration + factoryConfig, err := h.getConfig() + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to get configuration: %v", err)) + } + // Check if the service group exists - serviceGroup, exists := h.backupFactory.Config.Services[groupName] + serviceGroup, exists := factoryConfig.Services[groupName] if !exists { - return echo.NewHTTPError(404, "Service group not found") + return echo.NewHTTPError(http.StatusNotFound, "Service group not found") } // Create data structures - serviceBackups := make(map[string][]backup.BackupInfo) + serviceBackups := make(map[string][]strategy.BackupInfo) serviceConfigs := make(map[string]templates.ServiceProviderInfo) // Setup synchronization @@ -272,7 +296,7 @@ func (h *HomepageHandler) ServiceGroupAllBackups(c echo.Context) error { wg.Wait() // Create a map with just the group for the template - groupServiceBackups := make(map[string]map[string][]backup.BackupInfo) + groupServiceBackups := make(map[string]map[string][]strategy.BackupInfo) groupServiceBackups[groupName] = serviceBackups // Create a map with just the group configs for the template @@ -315,3 +339,15 @@ func (h *HomepageHandler) ServiceGroupAllBackups(c echo.Context) error { component := templates.ServiceGroupAllBackupsTable(groupName, serviceBackups, groupServiceConfigs) return component.Render(c.Request().Context(), c.Response().Writer) } + +// Helper method to get configuration from factory +func (h *HomepageHandler) getConfig() (*config.Configuration, error) { + // Type assert to get the concrete FactoryImpl type which has Config field + if factoryImpl, ok := h.backupFactory.(*strategy.FactoryImpl); ok { + return factoryImpl.Config, nil + } + + // If we can't access the Config directly, get it through factory methods + // This is where we'd need to add methods to the Factory interface if not already there + return nil, fmt.Errorf("cannot access configuration from factory") +} diff --git a/internal/web/routes/routes.go b/internal/web/routes/routes.go index 3ec3326..1532048 100644 --- a/internal/web/routes/routes.go +++ b/internal/web/routes/routes.go @@ -1,7 +1,7 @@ package routes import ( - "backea/internal/backup" + "backea/internal/backup/strategy" "backea/internal/web/handlers" "fmt" @@ -9,7 +9,7 @@ import ( ) // RegisterRoutes sets up all the routes for the web application -func RegisterRoutes(e *echo.Echo, factory *backup.BackupFactory) { +func RegisterRoutes(e *echo.Echo, factory strategy.Factory) { // Create handlers with backup factory homeHandler := handlers.NewHomepageHandler(factory) actionsHandler := handlers.NewBackupActionsHandler(factory) diff --git a/templates/home_templ.go b/templates/home_templ.go index 68be3bb..9bf0663 100644 --- a/templates/home_templ.go +++ b/templates/home_templ.go @@ -5,15 +5,16 @@ package templates //lint:file-ignore SA4006 This context is only used if a nested component is present. -import "github.com/a-h/templ" -import templruntime "github.com/a-h/templ/runtime" - import ( "backea/internal/backup" + "backea/internal/backup/strategy" "backea/templates/layouts" "fmt" "sort" "time" + + "github.com/a-h/templ" + templruntime "github.com/a-h/templ/runtime" ) // FormatSize formats byte size to human-readable format @@ -156,7 +157,7 @@ func GetSortedBackups(serviceGroup map[string][]backup.BackupInfo) []BackupWithS } // Home renders the homepage with lazy-loaded backup information -func Home(serviceBackups map[string]map[string][]backup.BackupInfo, serviceConfigs map[string]map[string]ServiceProviderInfo, sortedGroupNames []string, groupDirectories map[string]string) templ.Component { +func Home(serviceBackups map[string]map[string][]strategy.BackupInfo, serviceConfigs map[string]map[string]ServiceProviderInfo, sortedGroupNames []string, groupDirectories map[string]string) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {