diff --git a/internal/backup/core/error.go b/internal/backup/core/error.go new file mode 100644 index 0000000..f007791 --- /dev/null +++ b/internal/backup/core/error.go @@ -0,0 +1,28 @@ +package core + +// BackupError represents an error that occurred during backup operations +type BackupError struct { + Message string + Cause error +} + +// Error implements the error interface +func (e *BackupError) Error() string { + if e.Cause != nil { + return e.Message + ": " + e.Cause.Error() + } + return e.Message +} + +// Unwrap returns the underlying cause of the error +func (e *BackupError) Unwrap() error { + return e.Cause +} + +// NewBackupError creates a new BackupError +func NewBackupError(message string, cause error) *BackupError { + return &BackupError{ + Message: message, + Cause: cause, + } +} diff --git a/internal/backup/core/executor.go b/internal/backup/core/executor.go index ea5913c..a823d88 100644 --- a/internal/backup/core/executor.go +++ b/internal/backup/core/executor.go @@ -3,10 +3,9 @@ package core import ( "backea/internal/backup/models" "backea/internal/backup/strategy" + "backea/internal/logging" "backea/internal/mail" "context" - "fmt" - "log" "sync" ) @@ -16,6 +15,7 @@ type Executor struct { Factory strategy.Factory Mailer *mail.Mailer concurrency int + Logger logging.Logger } // NewExecutor creates a new backup executor @@ -25,6 +25,7 @@ func NewExecutor(config *models.Configuration, factory strategy.Factory) *Execut Factory: factory, // Mailer: mailer, concurrency: 5, + Logger: *logging.GetLogger(), } } @@ -69,8 +70,8 @@ func (e *Executor) processAllServiceGroups(ctx context.Context) error { 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) + e.Logger.Error("Failed to backup service group %s: %v", group, err) + errs <- NewBackupError("backup failed for group "+group, err) } }(groupName) } @@ -92,8 +93,8 @@ func (e *Executor) processServiceGroup(ctx context.Context, groupName string) er // 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) + e.Logger.Error("Service group not found: %s", groupName) + return NewBackupError("service group not found: "+groupName, nil) } // Create hook runner @@ -101,8 +102,8 @@ func (e *Executor) processServiceGroup(ctx context.Context, groupName string) er // 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) + e.Logger.Error("Failed to execute before hook for group %s: %v", groupName, err) + return NewBackupError("before hook failed for group "+groupName, err) } // Process all services in the group in parallel @@ -120,8 +121,8 @@ func (e *Executor) processServiceGroup(ctx context.Context, groupName string) er 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) + e.Logger.Error("Failed to backup service %s.%s: %v", group, index, err) + errs <- NewBackupError("backup failed for "+group+"."+index, err) } }(groupName, configIndex) } @@ -130,17 +131,21 @@ func (e *Executor) processServiceGroup(ctx context.Context, groupName string) er 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 } + + // Execute the after hook once for the entire group + if err := hooks.RunAfterHook(); err != nil { + e.Logger.Error("Failed to execute after hook for group %s: %v", groupName, err) + // If no backup errors occurred, return the after hook error, + // otherwise prioritize the backup errors + if lastErr == nil { + lastErr = NewBackupError("after hook failed for group "+groupName, err) + } + } + return lastErr } @@ -149,28 +154,33 @@ func (e *Executor) processSpecificService(ctx context.Context, groupName string, // Get service configuration serviceGroup, exists := e.Config.Services[groupName] if !exists { - return fmt.Errorf("service group not found: %s", groupName) + return NewBackupError("service group not found: "+groupName, nil) } // Check if the config index exists _, exists = serviceGroup.BackupConfigs[configIndex] if !exists { - return fmt.Errorf("service index not found: %s.%s", groupName, configIndex) + return NewBackupError("service index not found: "+groupName+"."+configIndex, nil) } // 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) + e.Logger.Error("Failed to create backup strategy for service %s.%s: %v", groupName, configIndex, err) + return NewBackupError("failed to create backup strategy for "+groupName+"."+configIndex, err) } // Create and run service service := NewService( - fmt.Sprintf("%s.%s", groupName, configIndex), + groupName+"."+configIndex, serviceGroup.Source.Path, backupStrategy, e.Mailer, ) - return service.Backup(ctx) + + if err := service.Backup(ctx); err != nil { + return NewBackupError("backup execution failed for "+groupName+"."+configIndex, err) + } + + return nil } diff --git a/internal/backup/core/hooks.go b/internal/backup/core/hooks.go index ae5a46a..db8cbdf 100644 --- a/internal/backup/core/hooks.go +++ b/internal/backup/core/hooks.go @@ -1,56 +1,64 @@ package core import ( - "log" - "os" + "backea/internal/logging" "os/exec" ) -// HookRunner runs hooks before and after backups type HookRunner struct { Directory string BeforeHook string AfterHook string + logger *logging.Logger } -// NewHookRunner creates a new hook runner func NewHookRunner(directory, beforeHook, afterHook string) *HookRunner { return &HookRunner{ Directory: directory, BeforeHook: beforeHook, AfterHook: afterHook, + logger: logging.GetLogger(), } } -// 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) + h.logger.Info("Running before hook: %s", h.BeforeHook) + err := h.runCommand(h.BeforeHook) + if err != nil { + return NewBackupError("before hook execution failed", err) + } + return nil } -// 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) + h.logger.Info("Running after hook: %s", h.AfterHook) + err := h.runCommand(h.AfterHook) + if err != nil { + return NewBackupError("after hook execution failed", err) + } + return nil } -// 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() + h.logger.Info("Executing command in %s: %s", h.Directory, command) + + outputBytes, err := cmd.CombinedOutput() + if err != nil { + h.logger.ErrorWithStack(err, "Command execution failed: %s", string(outputBytes)) + return NewBackupError("command execution failed: "+string(outputBytes), err) + } + + h.logger.Debug("Command output: %s", string(outputBytes)) + return nil } diff --git a/internal/backup/core/service.go b/internal/backup/core/service.go index 8acaf3f..709ce87 100644 --- a/internal/backup/core/service.go +++ b/internal/backup/core/service.go @@ -3,74 +3,76 @@ package core import ( "backea/internal/backup/models" "backea/internal/backup/strategy" + "backea/internal/logging" "backea/internal/mail" "context" - "fmt" - "log" "os" "time" ) -// Service represents a service to be backed up type Service struct { Name string Directory string Strategy strategy.Strategy Mailer *mail.Mailer + logger *logging.Logger } -// NewService creates a new backup service func NewService(name string, directory string, strategy strategy.Strategy, mailer *mail.Mailer) *Service { return &Service{ Name: name, Directory: directory, Strategy: strategy, Mailer: mailer, + logger: logging.GetLogger(), } } -// Backup performs the backup for this service func (s *Service) Backup(ctx context.Context) error { - log.Printf("Backing up service: %s", s.Name) + s.logger.Info("Backing up service: %s", s.Name) startTime := time.Now() - // Ensure directory exists if _, err := os.Stat(s.Directory); os.IsNotExist(err) { - return fmt.Errorf("directory does not exist: %s", s.Directory) + return NewBackupError("directory does not exist: "+s.Directory, nil) } - // Execute the backup strategy if err := s.Strategy.Execute(ctx, s.Name, s.Directory); err != nil { if s.Mailer != nil { s.Mailer.SendErrorNotification(s.Name, err) } - return fmt.Errorf("backup failed: %w", err) + return NewBackupError("backup failed for "+s.Name, err) } - // Record backup completion duration := time.Since(startTime) - log.Printf("Backup completed for %s in %v", s.Name, duration) + s.logger.Info("Backup completed for %s in %v", s.Name, duration) - // Send success notification if s.Mailer != nil { s.Mailer.SendSuccessNotification(s.Name, duration) } - return nil } -// GetBackupInfos retrieves information about existing backups func (s *Service) GetBackupInfos(ctx context.Context) ([]models.BackupInfo, error) { - return s.Strategy.ListBackups(ctx, s.Name) + infos, err := s.Strategy.ListBackups(ctx, s.Name) + if err != nil { + return nil, NewBackupError("failed to list backups for "+s.Name, err) + } + return infos, nil } -// GetStorageUsage retrieves information about storage usage func (s *Service) GetStorageUsage(ctx context.Context) (*models.StorageUsageInfo, error) { - return s.Strategy.GetStorageUsage(ctx, s.Name) + usage, err := s.Strategy.GetStorageUsage(ctx, s.Name) + if err != nil { + return nil, NewBackupError("failed to get storage usage for "+s.Name, err) + } + return usage, nil } -// 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) + s.logger.Info("Restoring backup %s for service %s", backupID, s.Name) + + if err := s.Strategy.RestoreBackup(ctx, backupID, s.Name); err != nil { + return NewBackupError("failed to restore backup "+backupID+" for "+s.Name, err) + } + return nil } diff --git a/internal/backup/models/config.go b/internal/backup/models/config.go index d4fe65b..3aee20a 100644 --- a/internal/backup/models/config.go +++ b/internal/backup/models/config.go @@ -1,7 +1,7 @@ package models import ( - "log" + "backea/internal/logging" "github.com/joho/godotenv" "github.com/spf13/viper" @@ -9,10 +9,17 @@ import ( // Configuration holds the complete application configuration type Configuration struct { + Logging LoggingConfig `mapstructure:"logging"` Defaults DefaultsConfig `mapstructure:"defaults"` Services map[string]ServiceGroup `mapstructure:"services"` } +// LoggingConfig holds logging configuration +type LoggingConfig struct { + LogLevel string `mapstructure:"log_level"` + LogFile string `mapstructure:"log_file"` +} + // DefaultsConfig holds the default settings for all services type DefaultsConfig struct { Retention RetentionConfig `mapstructure:"retention"` @@ -71,27 +78,61 @@ type DestConfig struct { Username string `mapstructure:"username"` } +// parseLogLevel converts string log level to LogLevel +func parseLogLevel(level string) logging.LogLevel { + switch level { + case "debug": + return logging.DEBUG + case "info": + return logging.INFO + case "warn": + return logging.WARN + case "error": + return logging.ERROR + case "fatal": + return logging.FATAL + default: + return logging.INFO // Default to INFO if not specified or invalid + } +} + // LoadConfig loads the application configuration from a file func LoadConfig(configPath string) (*Configuration, error) { + // Create a logger first with default settings + logger := logging.GetLogger() + if err := godotenv.Load(); err != nil { - log.Printf("Warning: Error loading .env file: %v", err) + logger.Warn("Error loading .env file: %v", err) } viper.SetConfigFile(configPath) if err := viper.ReadInConfig(); err != nil { + logger.Error("Failed to read config file: %v", err) return nil, err } var config Configuration if err := viper.Unmarshal(&config); err != nil { + logger.Error("Failed to unmarshal config: %v", err) return nil, err } + // Initialize logger with config settings + logLevel := parseLogLevel(config.Logging.LogLevel) + logging.InitLogger(logLevel, config.Logging.LogFile) + + // Get the potentially reconfigured logger + logger = logging.GetLogger() + logger.Info("Initialized logger with level %v and output file %s", + config.Logging.LogLevel, config.Logging.LogFile) + // 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) { + logger.Debug("Applying default retention settings for service %s, config %s", + serviceName, configID) backupConfig.BackupStrategy.Retention = config.Defaults.Retention service.BackupConfigs[configID] = backupConfig } diff --git a/internal/backup/security/password.go b/internal/backup/security/password.go index a753015..12a1a0e 100644 --- a/internal/backup/security/password.go +++ b/internal/backup/security/password.go @@ -1,28 +1,48 @@ package security import ( + "backea/internal/logging" "bufio" "crypto/rand" "fmt" - "log" "os" "strings" ) -// GetOrCreatePassword retrieves a password from kopia.env or creates a new one if it doesn't exist +type SecurityError struct { + Message string + Cause error +} + +func (e *SecurityError) Error() string { + if e.Cause != nil { + return e.Message + ": " + e.Cause.Error() + } + return e.Message +} + +func (e *SecurityError) Unwrap() error { + return e.Cause +} + +func NewSecurityError(message string, cause error) *SecurityError { + return &SecurityError{ + Message: message, + Cause: cause, + } +} + func GetOrCreatePassword(serviceName string, length int) (string, error) { - // Define the expected key in the env file - // Replace dots with underscores for environment variable name + logger := logging.GetLogger() + 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) + return "", NewSecurityError("failed to open kopia.env", err) } defer file.Close() @@ -32,7 +52,6 @@ func GetOrCreatePassword(serviceName string, length int) (string, error) { 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 @@ -41,71 +60,56 @@ func GetOrCreatePassword(serviceName string, length int) (string, error) { } if err := scanner.Err(); err != nil { - return "", fmt.Errorf("error reading kopia.env: %w", err) + return "", NewSecurityError("error reading kopia.env", 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) + return "", NewSecurityError("failed to generate password", 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) + return "", NewSecurityError("failed to open kopia.env for writing", 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) + return "", NewSecurityError("failed to write to kopia.env", err) } - log.Printf("Created new password for service %s and stored in kopia.env", serviceName) + logger.Info("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 + return "", NewSecurityError("failed to generate random bytes", 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 + result[0] = 'A' + byte(randomBytes[0]%26) + result[1] = 'A' + byte(randomBytes[1]%26) + result[2] = 'a' + byte(randomBytes[2]%26) + result[3] = 'a' + byte(randomBytes[3]%26) + result[4] = '0' + byte(randomBytes[4]%10) + result[5] = '0' + byte(randomBytes[5]%10) - // 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)] } diff --git a/internal/backup/strategy/factory.go b/internal/backup/strategy/factory.go index 870d762..0504e46 100644 --- a/internal/backup/strategy/factory.go +++ b/internal/backup/strategy/factory.go @@ -3,25 +3,55 @@ package strategy import ( "backea/internal/backup/models" "backea/internal/backup/strategy/kopia" + "backea/internal/logging" "fmt" "os" "path/filepath" "time" ) +// StrategyError represents an error in strategy operations +type StrategyError struct { + Message string + Cause error +} + +func (e *StrategyError) Error() string { + if e.Cause != nil { + return e.Message + ": " + e.Cause.Error() + } + return e.Message +} + +func (e *StrategyError) Unwrap() error { + return e.Cause +} + +func NewStrategyError(message string, cause error) *StrategyError { + return &StrategyError{ + Message: message, + Cause: cause, + } +} + // Factory handles backup strategy creation type Factory struct { Config *models.Configuration ConfigPath string + logger *logging.Logger } // NewFactory initializes and returns a Factory func NewFactory(configPath string) (*Factory, error) { cfg, err := models.LoadConfig(configPath) if err != nil { - return nil, fmt.Errorf("failed to load config: %w", err) + return nil, NewStrategyError("failed to load config", err) } - return &Factory{Config: cfg, ConfigPath: configPath}, nil + return &Factory{ + Config: cfg, + ConfigPath: configPath, + logger: logging.GetLogger(), + }, nil } // CreateBackupStrategyForService returns a backup strategy based on config @@ -30,6 +60,7 @@ func (f *Factory) CreateBackupStrategyForService(groupName, serviceIndex string) if err != nil { return nil, err } + sourcePath, err := f.getServicePath(groupName) if err != nil { return nil, err @@ -39,7 +70,7 @@ func (f *Factory) CreateBackupStrategyForService(groupName, serviceIndex string) case "kopia": return f.createKopiaStrategy(serviceConfig, sourcePath) default: - return nil, fmt.Errorf("unknown strategy type: %s", serviceConfig.BackupStrategy.Type) + return nil, NewStrategyError(fmt.Sprintf("unknown strategy type: %s", serviceConfig.BackupStrategy.Type), nil) } } @@ -47,7 +78,7 @@ func (f *Factory) CreateBackupStrategyForService(groupName, serviceIndex string) func (f *Factory) createKopiaStrategy(serviceConfig *models.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) + return nil, NewStrategyError("failed to create kopia provider", err) } retention := kopia.Retention(serviceConfig.BackupStrategy.Retention) @@ -61,16 +92,19 @@ func (f *Factory) createKopiaProvider(strategyConfig models.StrategyConfig) (kop switch strategyConfig.Provider { case "local": return kopia.NewLocalProvider(strategyConfig.Destination.Path), nil + case "b2", "backblaze": return kopia.NewB2Provider(), nil + case "sftp": host := strategyConfig.Destination.Host basepath := strategyConfig.Destination.Path username := strategyConfig.Destination.Username keyFile := strategyConfig.Destination.SSHKey return kopia.NewSFTPProvider(host, basepath, username, keyFile), nil + default: - return nil, fmt.Errorf("unknown kopia provider type: %s", strategyConfig.Provider) + return nil, NewStrategyError(fmt.Sprintf("unknown kopia provider type: %s", strategyConfig.Provider), nil) } } @@ -79,7 +113,7 @@ 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) + return "", NewStrategyError(fmt.Sprintf("service group not found: %s", groupName), nil) } // getServiceConfig returns a service's config @@ -88,15 +122,16 @@ func (f *Factory) getServiceConfig(groupName, serviceIndex string) (*models.Back if backupConfig, exists := serviceGroup.BackupConfigs[serviceIndex]; exists { return &backupConfig, nil } + return nil, NewStrategyError(fmt.Sprintf("service index not found: %s.%s", groupName, serviceIndex), nil) } - return nil, fmt.Errorf("backup config not found: %s.%s", groupName, serviceIndex) + return nil, NewStrategyError(fmt.Sprintf("service group not found: %s", groupName), nil) } // ReloadConfig reloads configuration func (f *Factory) ReloadConfig() error { cfg, err := models.LoadConfig(f.ConfigPath) if err != nil { - return fmt.Errorf("could not reload config: %w", err) + return NewStrategyError("could not reload config", err) } f.Config = cfg return nil diff --git a/internal/backup/strategy/kopia/b2.go b/internal/backup/strategy/kopia/b2.go index fb44a28..44916de 100644 --- a/internal/backup/strategy/kopia/b2.go +++ b/internal/backup/strategy/kopia/b2.go @@ -1,9 +1,9 @@ package kopia import ( + "backea/internal/logging" "context" "fmt" - "log" "os" "os/exec" "strings" @@ -11,42 +11,71 @@ import ( b2_client "backea/internal/client" ) +// KopiaError represents an error in Kopia operations +type KopiaError struct { + Message string + Cause error +} + +func (e *KopiaError) Error() string { + if e.Cause != nil { + return e.Message + ": " + e.Cause.Error() + } + return e.Message +} + +func (e *KopiaError) Unwrap() error { + return e.Cause +} + +func NewKopiaError(message string, cause error) *KopiaError { + return &KopiaError{ + Message: message, + Cause: cause, + } +} + // B2Provider implements the Provider interface for Backblaze B2 -type B2Provider struct{} +type B2Provider struct { + logger *logging.Logger +} // NewB2Provider creates a new B2 provider func NewB2Provider() *B2Provider { - return &B2Provider{} + return &B2Provider{ + logger: logging.GetLogger(), + } } // 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") + return NewKopiaError("B2_KEY_ID and B2_APPLICATION_KEY must be set in .env file", nil) } 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") + B2Client, err := b2_client.NewClientFromEnv() + if err != nil { + return NewKopiaError("failed to initialize B2 client", err) } - _, err := B2Client.GetBucket(ctx, bucketName) + if B2Client == nil { + return NewKopiaError("B2 client not initialized", nil) + } + + _, err = B2Client.GetBucket(ctx, bucketName) if err != nil { - log.Printf("Bucket %s not found, creating...", bucketName) + p.logger.Info("Bucket %s not found, creating...", bucketName) _, err = B2Client.CreateBucket(ctx, bucketName, false) if err != nil { - return fmt.Errorf("failed to create bucket: %w", err) + return NewKopiaError("failed to create bucket", err) } - log.Printf("Created bucket: %s", bucketName) + p.logger.Info("Created bucket: %s", bucketName) } - // Try to connect to existing repository with config file connectCmd := exec.CommandContext( ctx, "kopia", @@ -57,10 +86,10 @@ func (p *B2Provider) Connect(ctx context.Context, serviceName string, password s "--key", applicationKey, "--password", password, ) + err = connectCmd.Run() if err != nil { - // Connection failed, create new repository - log.Printf("Creating new B2 repository for %s", serviceName) + p.logger.Info("Creating new B2 repository for %s", serviceName) createCmd := exec.CommandContext( ctx, "kopia", @@ -71,9 +100,10 @@ func (p *B2Provider) Connect(ctx context.Context, serviceName string, password s "--key", applicationKey, "--password", password, ) + createOutput, err := createCmd.CombinedOutput() if err != nil { - return fmt.Errorf("failed to create repository: %w\nOutput: %s", err, createOutput) + return NewKopiaError(fmt.Sprintf("failed to create repository: %s", string(createOutput)), err) } } @@ -85,11 +115,10 @@ 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") + return nil, NewKopiaError("B2_KEY_ID and B2_APPLICATION_KEY must be set in .env file", nil) } bucketName := p.GetBucketName(serviceName) - return []string{ "b2", "--bucket", bucketName, @@ -100,10 +129,7 @@ func (p *B2Provider) GetRepositoryParams(serviceName string) ([]string, error) { // 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) } diff --git a/internal/backup/strategy/kopia/local.go b/internal/backup/strategy/kopia/local.go index 1a070ab..b09061a 100644 --- a/internal/backup/strategy/kopia/local.go +++ b/internal/backup/strategy/kopia/local.go @@ -1,9 +1,9 @@ package kopia import ( + "backea/internal/logging" "context" "fmt" - "log" "os" "os/exec" "path/filepath" @@ -12,29 +12,29 @@ import ( // LocalProvider implements the Provider interface for local storage type LocalProvider struct { BasePath string + logger *logging.Logger } // 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, + logger: logging.GetLogger(), } } // 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) + p.logger.Info("Connecting to local repository at %s with config: %s", repoPath, configPath) if err := os.MkdirAll(repoPath, 0755); err != nil { - return fmt.Errorf("failed to create repository directory: %w", err) + return NewKopiaError("failed to create repository directory", err) } - // Try to connect to existing repository with config file connectCmd := exec.CommandContext( ctx, "kopia", @@ -43,10 +43,10 @@ func (p *LocalProvider) Connect(ctx context.Context, serviceName string, passwor "--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) + p.logger.Info("Creating new local repository for %s at destination: %s", serviceName, repoPath) createCmd := exec.CommandContext( ctx, "kopia", @@ -55,9 +55,10 @@ func (p *LocalProvider) Connect(ctx context.Context, serviceName string, passwor "--path", repoPath, "--password", password, ) + createOutput, err := createCmd.CombinedOutput() if err != nil { - return fmt.Errorf("failed to create repository: %w\nOutput: %s", err, createOutput) + return NewKopiaError(fmt.Sprintf("failed to create repository: %s", string(createOutput)), err) } } diff --git a/internal/backup/strategy/kopia/sftp.go b/internal/backup/strategy/kopia/sftp.go index 5e8dfc9..10b741d 100644 --- a/internal/backup/strategy/kopia/sftp.go +++ b/internal/backup/strategy/kopia/sftp.go @@ -1,9 +1,9 @@ package kopia import ( + "backea/internal/logging" "context" "fmt" - "log" "os" "os/exec" "path/filepath" @@ -15,6 +15,7 @@ type SFTPProvider struct { BasePath string Username string KeyFile string + logger *logging.Logger } // NewSFTPProvider creates a new SFTP provider @@ -24,20 +25,14 @@ func NewSFTPProvider(host, basePath, username, keyFile string) *SFTPProvider { BasePath: basePath, Username: username, KeyFile: keyFile, + logger: logging.GetLogger(), } } -// kopia repository connect sftp \ -// --path="/home/gevo/kopiatemp" \ -// --host="maric.ro" \ -// --username="gevo" \ -// --keyfile="$(realpath ~/.ssh/kopia_key)" \ -// --known-hosts="$(realpath ~/.ssh/known_hosts)" func (p *SFTPProvider) Connect(ctx context.Context, serviceName string, password string, configPath string) error { - // Just use the path on the remote server, not the full URI repoPath := fmt.Sprintf("%s/%s", p.BasePath, serviceName) + knownHostsPath := filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts") - // Try to connect to existing repository with config file connectCmd := exec.CommandContext( ctx, "kopia", @@ -47,14 +42,13 @@ func (p *SFTPProvider) Connect(ctx context.Context, serviceName string, password "--host", p.Host, "--username", p.Username, "--keyfile", p.KeyFile, - "--known-hosts", filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts"), + "--known-hosts", knownHostsPath, "--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) + p.logger.Info("Creating new SFTP repository for %s at %s", serviceName, repoPath) createCmd := exec.CommandContext( ctx, "kopia", @@ -64,13 +58,13 @@ func (p *SFTPProvider) Connect(ctx context.Context, serviceName string, password "--host", p.Host, "--username", p.Username, "--keyfile", p.KeyFile, - "--known-hosts", filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts"), + "--known-hosts", knownHostsPath, "--password", password, ) createOutput, err := createCmd.CombinedOutput() if err != nil { - return fmt.Errorf("failed to create repository: %w\nOutput: %s", err, createOutput) + return NewKopiaError(fmt.Sprintf("failed to create SFTP repository: %s", string(createOutput)), err) } } @@ -80,11 +74,13 @@ func (p *SFTPProvider) Connect(ctx context.Context, serviceName string, password // 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) + knownHostsPath := filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts") + return []string{ "sftp", "--path", repoPath, "--keyfile", p.KeyFile, - "--known-hosts", filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts"), + "--known-hosts", knownHostsPath, }, nil } diff --git a/internal/backup/strategy/kopia/strategy.go b/internal/backup/strategy/kopia/strategy.go index 39e3915..7c2a2f3 100644 --- a/internal/backup/strategy/kopia/strategy.go +++ b/internal/backup/strategy/kopia/strategy.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "io" - "log" "os" "os/exec" "path/filepath" @@ -16,6 +15,7 @@ import ( "backea/internal/backup/models" "backea/internal/backup/security" + "backea/internal/logging" ) // Strategy implements the backup strategy using Kopia @@ -24,6 +24,7 @@ type Strategy struct { Provider Provider ConfigPath string SourcePath string + logger *logging.Logger } // Retention represents retention policy for backups @@ -43,41 +44,42 @@ func NewStrategy(retention Retention, provider Provider, configPath string, sour Provider: provider, ConfigPath: configPath, SourcePath: sourcePath, + logger: logging.GetLogger(), } } // Execute performs the kopia backup func (s *Strategy) Execute(ctx context.Context, serviceName, directory string) error { - log.Printf("Performing kopia backup for service %s: %s", serviceName, directory) + s.logger.Info("Performing kopia backup for service %s: %s", serviceName, directory) // Get or create password for this service password, err := security.GetOrCreatePassword(serviceName, 48) if err != nil { - return fmt.Errorf("failed to get or create password: %w", err) + return NewKopiaError("failed to get or create password", err) } // Ensure Kopia config directory exists kopiaConfigDir := filepath.Join(os.Getenv("HOME"), ".kopia") if err := os.MkdirAll(kopiaConfigDir, 0755); err != nil { - return fmt.Errorf("failed to create kopia config directory: %w", err) + return NewKopiaError("failed to create kopia config directory", err) } // Repository not connected, connect or create via provider - log.Printf("Connecting to repository for %s", serviceName) + s.logger.Info("Connecting to repository for %s", serviceName) if err := s.Provider.Connect(ctx, serviceName, password, s.ConfigPath); err != nil { - return fmt.Errorf("failed to connect to repository: %w", err) + return NewKopiaError("failed to connect to repository", err) } // Create snapshot - log.Printf("Creating snapshot for directory: %s", directory) + s.logger.Info("Creating snapshot for directory: %s", directory) snapshotCmd := exec.Command("kopia", "--config-file", s.ConfigPath, "snapshot", "create", directory) snapshotOutput, err := snapshotCmd.CombinedOutput() if err != nil { - return fmt.Errorf("failed to create snapshot: %w\nOutput: %s", err, snapshotOutput) + return NewKopiaError(fmt.Sprintf("failed to create snapshot: %s", string(snapshotOutput)), err) } // Set retention policy - log.Printf("Setting retention policy for %s", serviceName) + s.logger.Info("Setting retention policy for %s", serviceName) args := []string{ "--config-file", s.ConfigPath, "policy", "set", @@ -92,10 +94,10 @@ func (s *Strategy) Execute(ctx context.Context, serviceName, directory string) e policyCmd := exec.Command("kopia", args...) policyOutput, err := policyCmd.CombinedOutput() if err != nil { - return fmt.Errorf("failed to set policy: %w\nOutput: %s", err, policyOutput) + return NewKopiaError(fmt.Sprintf("failed to set policy: %s", string(policyOutput)), err) } - log.Printf("Snapshot and policy set successfully for %s", serviceName) + s.logger.Info("Snapshot and policy set successfully for %s", serviceName) return nil } @@ -104,13 +106,13 @@ func (s *Strategy) ListBackups(ctx context.Context, serviceName string) ([]model // Parse service group and index from service name _, _, err := parseServiceName(serviceName) if err != nil { - return nil, fmt.Errorf("invalid service name format: %w", err) + return nil, NewKopiaError("invalid service name format", 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") + return nil, NewKopiaError("source path not specified", nil) } // Trim trailing slash if any @@ -119,7 +121,7 @@ func (s *Strategy) ListBackups(ctx context.Context, serviceName string) ([]model // Ensure we're connected to the repository err = s.EnsureRepositoryConnected(ctx, serviceName) if err != nil { - return nil, fmt.Errorf("failed to connect to repository: %w", err) + return nil, NewKopiaError("failed to connect to repository", err) } // Run kopia snapshot list command with JSON output @@ -134,13 +136,13 @@ func (s *Strategy) ListBackups(ctx context.Context, serviceName string) ([]model output, err := cmd.Output() if err != nil { - return nil, fmt.Errorf("failed to list snapshots: %w", err) + return nil, NewKopiaError("failed to list snapshots", err) } // Parse the JSON output var snapshots []map[string]interface{} if err := json.Unmarshal(output, &snapshots); err != nil { - return nil, fmt.Errorf("failed to parse snapshot list: %w", err) + return nil, NewKopiaError("failed to parse snapshot list", err) } // Convert to BackupInfo @@ -209,7 +211,7 @@ func (s *Strategy) GetStorageUsage(ctx context.Context, serviceName string) (*mo // Ensure we're connected to the repository err := s.EnsureRepositoryConnected(ctx, serviceName) if err != nil { - return nil, fmt.Errorf("failed to connect to repository: %w", err) + return nil, NewKopiaError("failed to connect to repository", err) } // Get provider type (b2, local, sftp) @@ -245,7 +247,7 @@ func (s *Strategy) GetStorageUsage(ctx context.Context, serviceName string) (*mo if err == nil { // Parse the text output outputStr := string(blobStatsOutput) - log.Printf("Blob stats output: %s", outputStr) + s.logger.Debug("Blob stats output: %s", outputStr) // Look for the line with "Total:" lines := strings.Split(outputStr, "\n") @@ -258,42 +260,42 @@ func (s *Strategy) GetStorageUsage(ctx context.Context, serviceName string) (*mo size, err := parseHumanSize(sizeStr) if err == nil { info.TotalBytes = size - log.Printf("Got physical size from blob stats: %d bytes", size) + s.logger.Debug("Got physical size from blob stats: %d bytes", size) break } else { - log.Printf("Failed to parse size '%s': %v", sizeStr, err) + s.logger.Warn("Failed to parse size '%s': %v", sizeStr, err) } } } } } else { - log.Printf("Blob stats command failed: %v - %s", err, string(blobStatsOutput)) + s.logger.Warn("Blob stats command failed: %v - %s", err, string(blobStatsOutput)) } return info, nil } -// RestoreBackup restores a backup with the given ID +// RestoreBackup restores a backup by ID 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) + return NewKopiaError("failed to connect to repository", err) } // Get source directory from strategy instead of importing factory targetDir := s.SourcePath if targetDir == "" { - return fmt.Errorf("source path not specified") + return NewKopiaError("source path not specified", nil) } // Create a temporary directory for restore restoreDir := filepath.Join(os.TempDir(), fmt.Sprintf("backea-restore-%s-%d", serviceName, time.Now().Unix())) if err := os.MkdirAll(restoreDir, 0755); err != nil { - return fmt.Errorf("failed to create restore directory: %w", err) + return NewKopiaError("failed to create restore directory", err) } - log.Printf("Restoring backup %s to temporary directory %s", backupID, restoreDir) + s.logger.Info("Restoring backup %s to temporary directory %s", backupID, restoreDir) // Run kopia restore command to restore the snapshot to the temporary directory restoreCmd := exec.CommandContext( @@ -308,11 +310,11 @@ func (s *Strategy) RestoreBackup(ctx context.Context, backupID string, serviceNa restoreOutput, err := restoreCmd.CombinedOutput() if err != nil { - return fmt.Errorf("failed to restore snapshot: %w\nOutput: %s", err, restoreOutput) + return NewKopiaError(fmt.Sprintf("failed to restore snapshot: %s", string(restoreOutput)), err) } // Now we need to sync the restored data to the original directory - log.Printf("Syncing restored data from %s to %s", restoreDir, targetDir) + s.logger.Info("Syncing restored data from %s to %s", restoreDir, targetDir) // Use rsync for the final transfer to avoid permissions issues syncCmd := exec.CommandContext( @@ -326,17 +328,17 @@ func (s *Strategy) RestoreBackup(ctx context.Context, backupID string, serviceNa syncOutput, err := syncCmd.CombinedOutput() if err != nil { - return fmt.Errorf("failed to sync restored data: %w\nOutput: %s", err, syncOutput) + return NewKopiaError(fmt.Sprintf("failed to sync restored data: %s", string(syncOutput)), err) } // Clean up the temporary directory go func() { time.Sleep(5 * time.Minute) // Wait 5 minutes before cleaning up - log.Printf("Cleaning up temporary restore directory %s", restoreDir) + s.logger.Info("Cleaning up temporary restore directory %s", restoreDir) os.RemoveAll(restoreDir) }() - log.Printf("Successfully restored backup %s to %s", backupID, targetDir) + s.logger.Info("Successfully restored backup %s to %s", backupID, targetDir) return nil } @@ -345,20 +347,19 @@ func (s *Strategy) DownloadBackup(ctx context.Context, backupID string, serviceN // Ensure repository is connected err := s.EnsureRepositoryConnected(ctx, serviceName) if err != nil { - return nil, fmt.Errorf("failed to connect to repository: %w", err) + return nil, NewKopiaError("failed to connect to repository", err) } // Create a single temporary directory for restore and ZIP creation - // Note: We're not creating a nested "restore" subdirectory anymore tempDir, err := os.MkdirTemp("", "backea-download-*") if err != nil { - return nil, fmt.Errorf("failed to create temporary directory: %w", err) + return nil, NewKopiaError("failed to create temporary directory", err) } zipFile := filepath.Join(tempDir, "backup.zip") // Restore the snapshot directly to the temporary directory - log.Printf("Restoring snapshot %s to temporary directory %s", backupID, tempDir) + s.logger.Info("Restoring snapshot %s to temporary directory %s", backupID, tempDir) restoreCmd := exec.CommandContext( ctx, @@ -367,23 +368,23 @@ func (s *Strategy) DownloadBackup(ctx context.Context, backupID string, serviceN "snapshot", "restore", backupID, - tempDir, // Restore directly to tempDir instead of a subdirectory + tempDir, ) restoreOutput, err := restoreCmd.CombinedOutput() if err != nil { os.RemoveAll(tempDir) - return nil, fmt.Errorf("failed to restore snapshot: %w\nOutput: %s", err, restoreOutput) + return nil, NewKopiaError(fmt.Sprintf("failed to restore snapshot: %s", string(restoreOutput)), err) } // Create ZIP archive of the restored files - log.Printf("Creating ZIP archive at %s", zipFile) + s.logger.Info("Creating ZIP archive at %s", zipFile) // Use Go's zip package instead of command line tools zipWriter, err := os.Create(zipFile) if err != nil { os.RemoveAll(tempDir) - return nil, fmt.Errorf("failed to create zip file: %w", err) + return nil, NewKopiaError("failed to create zip file", err) } defer zipWriter.Close() @@ -396,7 +397,7 @@ func (s *Strategy) DownloadBackup(ctx context.Context, backupID string, serviceN err = filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error { // Handle errors accessing files/directories if err != nil { - log.Printf("Warning: Error accessing path %s: %v", path, err) + s.logger.Warn("Error accessing path %s: %v", path, err) return nil // Skip this file but continue walking } @@ -413,14 +414,14 @@ func (s *Strategy) DownloadBackup(ctx context.Context, backupID string, serviceN // Create ZIP header header, err := zip.FileInfoHeader(info) if err != nil { - log.Printf("Warning: Couldn't create header for %s: %v", path, err) + s.logger.Warn("Couldn't create header for %s: %v", path, err) return nil // Skip this file but continue walking } // Make the path relative to the temp directory relPath, err := filepath.Rel(tempDir, path) if err != nil { - log.Printf("Warning: Couldn't get relative path for %s: %v", path, err) + s.logger.Warn("Couldn't get relative path for %s: %v", path, err) return nil // Skip this file but continue walking } @@ -430,14 +431,14 @@ func (s *Strategy) DownloadBackup(ctx context.Context, backupID string, serviceN // Create the file in the ZIP writer, err := archive.CreateHeader(header) if err != nil { - log.Printf("Warning: Couldn't create zip entry for %s: %v", path, err) + s.logger.Warn("Couldn't create zip entry for %s: %v", path, err) return nil // Skip this file but continue walking } // Open the file file, err := os.Open(path) if err != nil { - log.Printf("Warning: Couldn't open file %s: %v", path, err) + s.logger.Warn("Couldn't open file %s: %v", path, err) return nil // Skip this file but continue walking } defer file.Close() @@ -445,7 +446,7 @@ func (s *Strategy) DownloadBackup(ctx context.Context, backupID string, serviceN // Copy the file content to the ZIP _, err = io.Copy(writer, file) if err != nil { - log.Printf("Warning: Error copying content from %s: %v", path, err) + s.logger.Warn("Error copying content from %s: %v", path, err) } return nil // Continue to next file regardless of error }) @@ -453,7 +454,7 @@ func (s *Strategy) DownloadBackup(ctx context.Context, backupID string, serviceN // Even if we had some file errors, don't fail the whole process // as long as we created the zip file if err != nil { - log.Printf("Some files may have been skipped during zip creation: %v", err) + s.logger.Warn("Some files may have been skipped during zip creation: %v", err) } // Close the ZIP writer before opening it for reading @@ -464,7 +465,7 @@ func (s *Strategy) DownloadBackup(ctx context.Context, backupID string, serviceN zipReader, err := os.Open(zipFile) if err != nil { os.RemoveAll(tempDir) - return nil, fmt.Errorf("failed to open zip archive: %w", err) + return nil, NewKopiaError("failed to open zip archive", err) } // Return a reader that will clean up when closed @@ -481,7 +482,7 @@ func (s *Strategy) DownloadBackup(ctx context.Context, backupID string, serviceN 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) + return nil, NewKopiaError("failed to connect to repository", err) } // Run kopia snapshot describe command with JSON output @@ -497,13 +498,13 @@ func (s *Strategy) GetBackupInfo(ctx context.Context, backupID string, serviceNa output, err := cmd.Output() if err != nil { - return nil, fmt.Errorf("failed to describe snapshot: %w", err) + return nil, NewKopiaError("failed to describe snapshot", err) } // Parse the JSON output var snap map[string]interface{} if err := json.Unmarshal(output, &snap); err != nil { - return nil, fmt.Errorf("failed to parse snapshot info: %w", err) + return nil, NewKopiaError("failed to parse snapshot info", err) } // Extract the relevant information @@ -566,12 +567,12 @@ func (s *Strategy) EnsureRepositoryConnected(ctx context.Context, serviceName st // Repository not connected, try to connect password, err := security.GetOrCreatePassword(serviceName, 48) if err != nil { - return fmt.Errorf("failed to get password: %w", err) + return NewKopiaError("failed to get password", 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 NewKopiaError("failed to connect to repository", err) } } @@ -582,7 +583,7 @@ func (s *Strategy) EnsureRepositoryConnected(ctx context.Context, serviceName st 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 "", "", NewKopiaError(fmt.Sprintf("service name must be in format 'group.index', got '%s'", serviceName), nil) } return parts[0], parts[1], nil } @@ -591,12 +592,12 @@ func parseServiceName(serviceName string) (string, string, error) { func parseHumanSize(sizeStr string) (int64, error) { parts := strings.Fields(sizeStr) if len(parts) != 2 { - return 0, fmt.Errorf("invalid size format: %s", sizeStr) + return 0, NewKopiaError(fmt.Sprintf("invalid size format: %s", sizeStr), nil) } value, err := strconv.ParseFloat(parts[0], 64) if err != nil { - return 0, fmt.Errorf("invalid size value: %w", err) + return 0, NewKopiaError("invalid size value", err) } unit := strings.ToUpper(parts[1]) @@ -612,7 +613,7 @@ func parseHumanSize(sizeStr string) (int64, error) { case "TB", "TIB": return int64(value * 1024 * 1024 * 1024 * 1024), nil default: - return 0, fmt.Errorf("unknown size unit: %s", unit) + return 0, NewKopiaError(fmt.Sprintf("unknown size unit: %s", unit), nil) } } diff --git a/internal/backup/strategy/types.go b/internal/backup/strategy/types.go index 622e24b..09fe06a 100644 --- a/internal/backup/strategy/types.go +++ b/internal/backup/strategy/types.go @@ -26,20 +26,20 @@ type StorageUsageInfo struct { // Strategy defines the backup/restore strategy interface type Strategy interface { - // Execute performs a backup + // Execute performs the backup operation Execute(ctx context.Context, serviceName, directory string) error - // ListBackups gets information about existing backups + // ListBackups returns information about existing backups ListBackups(ctx context.Context, serviceName string) ([]models.BackupInfo, error) - // GetStorageUsage returns information about the total storage used + // GetStorageUsage returns information about storage usage GetStorageUsage(ctx context.Context, serviceName string) (*models.StorageUsageInfo, error) - // RestoreBackup restores a backup to its original location + // RestoreBackup restores a backup by ID 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) + // DownloadBackup provides a reader with backup summary information + DownloadBackup(ctx context.Context, backupID string, serviceName string) (io.ReadCloser, error) // GetBackupInfo returns detailed information about a specific backup GetBackupInfo(ctx context.Context, backupID string, serviceName string) (*models.BackupInfo, error) @@ -75,11 +75,3 @@ type RetentionConfig struct { 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/client/b2_client.go b/internal/client/b2_client.go index 326deb6..d46fe40 100644 --- a/internal/client/b2_client.go +++ b/internal/client/b2_client.go @@ -1,6 +1,7 @@ package b2_client import ( + "backea/internal/logging" "context" "fmt" "os" @@ -9,24 +10,50 @@ import ( "gopkg.in/kothar/go-backblaze.v0" ) +// B2Error represents an error in B2 client operations +type B2Error struct { + Message string + Cause error +} + +func (e *B2Error) Error() string { + if e.Cause != nil { + return e.Message + ": " + e.Cause.Error() + } + return e.Message +} + +func (e *B2Error) Unwrap() error { + return e.Cause +} + +func NewB2Error(message string, cause error) *B2Error { + return &B2Error{ + Message: message, + Cause: cause, + } +} + // Client represents a B2 client type Client struct { - b2 *backblaze.B2 + b2 *backblaze.B2 + logger *logging.Logger } // NewClientFromEnv creates a new B2 client using credentials from .env func NewClientFromEnv() (*Client, error) { + logger := logging.GetLogger() + // Load .env file if err := godotenv.Load(); err != nil { - return nil, fmt.Errorf("error loading .env file: %w", err) + return nil, NewB2Error("error loading .env file", err) } // Get credentials from environment 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") + return nil, NewB2Error("B2_KEY_ID and B2_APPLICATION_KEY must be set in .env file", nil) } // Create B2 client @@ -34,13 +61,13 @@ func NewClientFromEnv() (*Client, error) { AccountID: keyID, ApplicationKey: applicationKey, }) - if err != nil { - return nil, fmt.Errorf("error creating B2 client: %w", err) + return nil, NewB2Error("error creating B2 client", err) } return &Client{ - b2: b2, + b2: b2, + logger: logger, }, nil } @@ -49,9 +76,8 @@ func (c *Client) ListBuckets(ctx context.Context) ([]*backblaze.Bucket, error) { // List all buckets buckets, err := c.b2.ListBuckets() if err != nil { - return nil, fmt.Errorf("error listing buckets: %w", err) + return nil, NewB2Error("error listing buckets", err) } - return buckets, nil } @@ -60,7 +86,7 @@ func (c *Client) GetBucket(ctx context.Context, name string) (*backblaze.Bucket, // List all buckets buckets, err := c.b2.ListBuckets() if err != nil { - return nil, fmt.Errorf("error listing buckets: %w", err) + return nil, NewB2Error("error listing buckets", err) } // Find bucket by name @@ -70,7 +96,7 @@ func (c *Client) GetBucket(ctx context.Context, name string) (*backblaze.Bucket, } } - return nil, fmt.Errorf("bucket not found: %s", name) + return nil, NewB2Error(fmt.Sprintf("bucket not found: %s", name), nil) } // CreateBucket creates a new bucket @@ -84,8 +110,9 @@ func (c *Client) CreateBucket(ctx context.Context, name string, public bool) (*b // Create bucket bucket, err := c.b2.CreateBucket(name, bucketType) if err != nil { - return nil, fmt.Errorf("error creating bucket: %w", err) + return nil, NewB2Error(fmt.Sprintf("error creating bucket '%s'", name), err) } + c.logger.Info("Created B2 bucket: %s", name) return bucket, nil } diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..be32369 --- /dev/null +++ b/internal/logging/logging.go @@ -0,0 +1,147 @@ +// internal/logging/logging.go +package logging + +import ( + "fmt" + "io" + "log" + "os" + "path/filepath" + "runtime" + "sync" +) + +var ( + // Global logger instance + logger *Logger + once sync.Once +) + +// LogLevel represents the severity of a log message +type LogLevel int + +const ( + DEBUG LogLevel = iota + INFO + WARN + ERROR + FATAL +) + +// Logger provides logging functionality +type Logger struct { + logLevel LogLevel + output io.Writer + stdLog *log.Logger + errLog *log.Logger + mu sync.Mutex +} + +// Singleton +func InitLogger(level LogLevel, logFile string) { + once.Do(func() { + var output io.Writer = os.Stdout + + if logFile != "" { + logDir := filepath.Dir(logFile) + if err := os.MkdirAll(logDir, 0755); err != nil { + fmt.Fprintf(os.Stderr, "Failed to create log directory: %v\n", err) + os.Exit(1) + } + + file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to open log file: %v\n", err) + os.Exit(1) + } + + output = io.MultiWriter(os.Stdout, file) + } + + logger = &Logger{ + logLevel: level, + output: output, + stdLog: log.New(output, "", log.LstdFlags), + errLog: log.New(output, "ERROR: ", log.LstdFlags), + } + }) +} + +// Singleton +func GetLogger() *Logger { + if logger == nil { + InitLogger(INFO, "") + } + return logger +} + +func (l *Logger) SetLogLevel(level LogLevel) { + l.mu.Lock() + defer l.mu.Unlock() + l.logLevel = level +} + +func (l *Logger) log(level LogLevel, format string, v ...interface{}) { + if level < l.logLevel { + return + } + + l.mu.Lock() + defer l.mu.Unlock() + + // Get caller information + _, file, line, ok := runtime.Caller(2) + if !ok { + file = "unknown" + line = 0 + } + + // Format message with source location + message := fmt.Sprintf("%s:%d: %s", filepath.Base(file), line, fmt.Sprintf(format, v...)) + + if level >= ERROR { + l.errLog.Println(message) + } else { + l.stdLog.Println(message) + } +} + +// Debug logs a debug message +func (l *Logger) Debug(format string, v ...interface{}) { + l.log(DEBUG, format, v...) +} + +// Info logs an info message +func (l *Logger) Info(format string, v ...interface{}) { + l.log(INFO, format, v...) +} + +// Warn logs a warning message +func (l *Logger) Warn(format string, v ...interface{}) { + l.log(WARN, format, v...) +} + +// Error logs an error message +func (l *Logger) Error(format string, v ...interface{}) { + l.log(ERROR, format, v...) +} + +// Fatal logs a fatal message and exits the program +func (l *Logger) Fatal(format string, v ...interface{}) { + l.log(FATAL, format, v...) + os.Exit(1) +} + +// ErrorWithStack logs an error with its stack trace +func (l *Logger) ErrorWithStack(err error, format string, v ...interface{}) { + if err == nil { + return + } + + message := fmt.Sprintf(format, v...) + + buf := make([]byte, 1024) + n := runtime.Stack(buf, false) + + l.Error("%s: %v\n%s", message, err, buf[:n]) +}