Better logging system

This commit is contained in:
sirir 2025-03-28 19:57:44 +01:00
parent 94f4359946
commit 506070edcc
14 changed files with 551 additions and 233 deletions

View File

@ -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,
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)]
}

View File

@ -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

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
// }

View File

@ -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
}

147
internal/logging/logging.go Normal file
View File

@ -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])
}