Add backup models and refactor backup strategy implementation

This commit is contained in:
Romaric SIRI 2025-03-21 15:55:32 +01:00
parent d840597cb8
commit b3578ab982
30 changed files with 1136 additions and 1332 deletions

View File

@ -1,4 +1,4 @@
FROM golang:1.24-alpine AS builder
FROM docker.io/library/golang:1.24-alpine AS builder
WORKDIR /app
RUN apk add --no-cache git
@ -39,9 +39,8 @@ COPY --from=builder /app/backea-server .
COPY --from=builder /app/backup-performer .
COPY --from=builder /app/list-backups .
COPY --from=builder /app/static ./static
COPY --from=builder /app/templates ./templates
RUN chmod +x /app/backea-server /app/backup-performer /app/list-backups
CMD ["/app/backea-server"]
CMD ["/app/backea-server"]

View File

@ -1,7 +1,9 @@
package main
import (
"backea/internal/backup"
"backea/internal/backup/config"
"backea/internal/backup/core"
"backea/internal/backup/strategy"
"context"
"flag"
"log"
@ -19,19 +21,28 @@ func main() {
log.Printf("Warning: Error loading .env file: %v", err)
}
ctx := context.Background()
// Parse the service flag to extract group and index if provided
// Parse service name and index
var serviceName, serviceIndex string
if *serviceFlag != "" {
parts := strings.SplitN(*serviceFlag, "-", 2)
parts := strings.SplitN(*serviceFlag, ".", 2)
serviceName = parts[0]
if len(parts) > 1 {
serviceIndex = parts[1]
}
}
if err := backup.PerformBackups(ctx, *configPath, serviceName, serviceIndex); err != nil {
cfg, err := config.LoadConfig(*configPath)
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
factory, _ := strategy.NewFactory(*configPath)
executor := core.NewExecutor(cfg, *factory)
// Perform backups
ctx := context.Background()
if err := executor.PerformBackups(ctx, serviceName, serviceIndex); err != nil {
log.Fatalf("Backup failed: %v", err)
}
}

View File

@ -1,7 +1,7 @@
package main
import (
"backea/internal/backup"
"backea/internal/backup/strategy"
"context"
"fmt"
"log"
@ -20,12 +20,14 @@ func main() {
log.Printf("Warning: Error loading .env file: %v", err)
}
// Create backup factory
factory, err := backup.NewBackupFactory(configPath)
factoryBuilder := &strategy.DefaultFactoryBuilder{}
factory, err := factoryBuilder.BuildFactory(*configPath)
if err != nil {
log.Fatalf("Failed to create backup factory: %v", err)
}
config := factory.Config
// Process each service group
for groupName, serviceGroup := range factory.Config.Services {
fmt.Printf("Service Group: %s (Directory: %s)\n", groupName, serviceGroup.Source.Path)

View File

@ -7,19 +7,17 @@ services:
- "8080:8080"
volumes:
# Configuration files
- ./.env:/app/.env
- ./config.yml:/app/config.yml
- ./kopia.env:/app/kopia.env
- ./.env:/app/.env:Z
- ./config.yml:/app/config.yml:Z
- ./kopia.env:/app/kopia.env:Z
# Source directories to backup - map to identical paths inside container
- /home/gevo/Documents/backea2:/home/gevo/Documents/backea2
- /home/gevo/Images:/home/gevo/Images
- /home/sirir/Images:/home/sirir/Images
# Dedicated repository storage
- /home/gevo/.kopia:/root/.kopia
- ./kopiadata:/root/.kopia
# SSH keys if needed
- /home/gevo/.ssh:/root/.ssh:ro
- /home/sirir/.ssh:/root/.ssh:ro
- ./config/kopia:/tmp/kopia_configs
environment:
- HOME=/root
@ -31,4 +29,4 @@ services:
- apparmor:unconfined
restart: unless-stopped
env_file:
- ./.env
- ./.env

View File

@ -11,7 +11,7 @@ services:
backealocal:
source:
host: "local"
path: "/home/gevo/Documents/backea2"
path: "/home/sirir/Images"
hooks:
before_hook: "ls"
after_hook: ""
@ -21,18 +21,18 @@ services:
type: "kopia"
provider: "local"
destination:
path: "/home/gevo/Documents/backea2"
path: "/home/sirir/backup/image1"
2:
backup_strategy:
type: "kopia"
provider: "local"
destination:
path: "/home/gevo/Documents/backea2"
path: "/home/sirir/backup/images2"
imageslocal:
source:
host: "local"
path: "/home/gevo/Images/"
path: "/home/sirir/Images/"
hooks:
before_hook: "ls"
after_hook: ""
@ -43,16 +43,3 @@ services:
provider: "local"
destination:
host: "local"
b2backupstotozzeeeaaaeeee:
source:
host: "local"
path: "/home/gevo/Images/"
hooks:
before_hook: "ls"
after_hook: ""
backup_configs:
1:
backup_strategy:
type: "kopia"
provider: "b2_backblaze"

View File

@ -1,131 +0,0 @@
package backup
import (
"backea/internal/mail"
"context"
"fmt"
"log"
"sync"
)
// PerformBackups executes backups for multiple services based on configuration
func PerformBackups(ctx context.Context, configPath string, serviceName string, serviceIndex string) error {
// Create backup factory
factory, err := NewBackupFactory(configPath)
if err != nil {
return err
}
// Initialize mailer
mailer := mail.NewMailer()
// Process services
if serviceName != "" {
// Process single service group or specific service
if serviceIndex != "" {
// Process specific service within a group
return processSpecificService(ctx, factory, serviceName, serviceIndex, mailer)
} else {
// Process all services in the specified group
return processServiceGroup(ctx, factory, serviceName, mailer)
}
} else {
// Process all service groups in parallel
var wg sync.WaitGroup
errs := make(chan error, len(factory.Config.Services))
for groupName := range factory.Config.Services {
wg.Add(1)
go func(group string) {
defer wg.Done()
if err := processServiceGroup(ctx, factory, group, mailer); err != nil {
log.Printf("Failed to backup service group %s: %v", group, err)
errs <- fmt.Errorf("backup failed for group %s: %w", group, err)
}
}(groupName)
}
// Wait for all backups to complete
wg.Wait()
close(errs)
// Check if any errors occurred
var lastErr error
for err := range errs {
lastErr = err
}
return lastErr
}
}
// processServiceGroup handles the backup for all services in a group
func processServiceGroup(ctx context.Context, factory *BackupFactory, groupName string, mailer *mail.Mailer) error {
// Get service group configuration
serviceGroup, exists := factory.Config.Services[groupName]
if !exists {
log.Printf("Service group not found: %s", groupName)
return nil
}
// Execute the before hook once for the entire group
if serviceGroup.Hooks.BeforeHook != "" {
log.Printf("Executing before hook for group %s: %s", groupName, serviceGroup.Hooks.BeforeHook)
if err := RunCommand(serviceGroup.Source.Path, serviceGroup.Hooks.BeforeHook); err != nil {
log.Printf("Failed to execute before hook for group %s: %v", groupName, err)
return err
}
}
// Process all services in the group in parallel
var wg sync.WaitGroup
errs := make(chan error, len(serviceGroup.BackupConfigs))
for configIndex := range serviceGroup.BackupConfigs {
wg.Add(1)
go func(group, index string) {
defer wg.Done()
if err := processSpecificService(ctx, factory, group, index, mailer); err != nil {
log.Printf("Failed to backup service %s.%s: %v", group, index, err)
errs <- fmt.Errorf("backup failed for %s.%s: %w", group, index, err)
}
}(groupName, configIndex)
}
// Wait for all backups to complete
wg.Wait()
close(errs)
// Execute the after hook once for the entire group
if serviceGroup.Hooks.AfterHook != "" {
log.Printf("Executing after hook for group %s: %s", groupName, serviceGroup.Hooks.AfterHook)
if err := RunCommand(serviceGroup.Source.Path, serviceGroup.Hooks.AfterHook); err != nil {
log.Printf("Failed to execute after hook for group %s: %v", groupName, err)
// We don't return here because we want to process the errors from the backups
}
}
// Check if any errors occurred
var lastErr error
for err := range errs {
lastErr = err
}
return lastErr
}
// processSpecificService handles the backup for a specific service in a group
func processSpecificService(ctx context.Context, factory *BackupFactory, groupName string, configIndex string, mailer *mail.Mailer) error {
// Get service configuration
serviceGroup := factory.Config.Services[groupName]
// Create the appropriate backup strategy using the factory
strategy, err := factory.CreateBackupStrategyForService(groupName, configIndex)
if err != nil {
log.Printf("Failed to create backup strategy for service %s.%s: %v", groupName, configIndex, err)
return err
}
// Create and run service
service := NewService(
fmt.Sprintf("%s.%s", groupName, configIndex),
serviceGroup.Source.Path,
strategy,
mailer,
)
return service.Backup(ctx)
}

View File

@ -1,187 +0,0 @@
package backup
import (
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// BackupFactory creates backup strategies and managers
type BackupFactory struct {
Config *Configuration
ConfigPath string
}
// NewBackupFactory creates a new backup factory
func NewBackupFactory(configPath string) (*BackupFactory, error) {
// Load configuration
config, err := LoadConfig(configPath)
if err != nil {
return nil, fmt.Errorf("could not load config: %w", err)
}
return &BackupFactory{
Config: config,
ConfigPath: configPath,
}, nil
}
// CreateKopiaStrategy creates a new Kopia backup strategy with specific retention settings and provider
func (f *BackupFactory) CreateKopiaStrategy(retention Retention, provider KopiaProvider) *KopiaStrategy {
// Create temp directory for Kopia configs if it doesn't exist
tmpConfigDir := filepath.Join(os.TempDir(), "kopia_configs")
if err := os.MkdirAll(tmpConfigDir, 0755); err != nil {
log.Printf("Warning: failed to create temp config directory: %v", err)
// Fall back to default config path
tmpConfigDir = os.TempDir()
}
// Generate a unique config file path for this instance
configPath := filepath.Join(tmpConfigDir, fmt.Sprintf("kopia_%s_%d.config",
provider.GetProviderType(), time.Now().UnixNano()))
// Need to update this line to pass configPath
return NewKopiaStrategy(retention, provider, configPath)
}
// CreateKopiaProvider creates the appropriate Kopia provider based on config
func (f *BackupFactory) CreateKopiaProvider(strategyConfig StrategyConfig) (KopiaProvider, error) {
switch strategyConfig.Provider {
case "b2_backblaze":
return NewKopiaB2Provider(), nil
case "local":
// Extract options for local path if specified
basePath := ""
if strategyConfig.Options != "" {
basePath = strategyConfig.Options
} else {
// Default to ~/.backea/repos if not specified
basePath = filepath.Join(os.Getenv("HOME"), ".backea", "repos")
}
return NewKopiaLocalProvider(basePath), nil
case "sftp":
// Parse options for SFTP - expected format: "user@host:/path/to/backups"
host := strategyConfig.Destination.Host
path := strategyConfig.Destination.Path
sshKey := strategyConfig.Destination.SSHKey
if host == "" {
return nil, fmt.Errorf("SFTP provider requires host in destination config")
}
if path == "" {
return nil, fmt.Errorf("SFTP provider requires path in destination config")
}
// Default SSH key if not specified
if sshKey == "" {
sshKey = filepath.Join(os.Getenv("HOME"), ".ssh", "id_rsa")
}
// Extract username from host if in format user@host
username := "root" // default
if hostParts := strings.Split(host, "@"); len(hostParts) > 1 {
username = hostParts[0]
host = hostParts[1]
}
return NewKopiaSFTPProvider(host, path, username, sshKey), nil
default:
return nil, fmt.Errorf("unsupported Kopia provider: %s", strategyConfig.Provider)
}
}
// CreateBackupStrategyForService creates a backup strategy for the specified service within a group
func (f *BackupFactory) CreateBackupStrategyForService(groupName string, serviceIndex string) (Strategy, error) {
// Find service group
serviceGroup, exists := f.Config.Services[groupName]
if !exists {
return nil, fmt.Errorf("service group not found: %s", groupName)
}
// Find specific backup config within the group
backupConfig, exists := serviceGroup.BackupConfigs[serviceIndex]
if !exists {
return nil, fmt.Errorf("backup config not found: %s.%s", groupName, serviceIndex)
}
// Create appropriate strategy based on type
strategyConfig := backupConfig.BackupStrategy
// Extract retention settings once
retention := Retention{
KeepLatest: strategyConfig.Retention.KeepLatest,
KeepHourly: strategyConfig.Retention.KeepHourly,
KeepDaily: strategyConfig.Retention.KeepDaily,
KeepWeekly: strategyConfig.Retention.KeepWeekly,
KeepMonthly: strategyConfig.Retention.KeepMonthly,
KeepYearly: strategyConfig.Retention.KeepYearly,
}
switch strategyConfig.Type {
case "kopia":
// Create appropriate provider based on configuration
provider, err := f.CreateKopiaProvider(strategyConfig)
if err != nil {
return nil, fmt.Errorf("failed to create kopia provider: %w", err)
}
return f.CreateKopiaStrategy(retention, provider), nil
case "rsync":
// Uncomment when rsync implementation is ready
// return NewRsyncStrategy(
// strategyConfig.Options,
// Destination{
// Host: strategyConfig.Destination.Host,
// Path: strategyConfig.Destination.Path,
// SSHKey: strategyConfig.Destination.SSHKey,
// },
// ), nil
fallthrough
default:
// Default to local kopia if type is unknown or rsync (temporarily)
provider, _ := f.CreateKopiaProvider(StrategyConfig{Provider: "local"})
return f.CreateKopiaStrategy(retention, provider), nil
}
}
// ExecuteGroupHooks runs the hooks for a service group
func (f *BackupFactory) ExecuteGroupHooks(groupName string, isBeforeHook bool) error {
serviceGroup, exists := f.Config.Services[groupName]
if !exists {
return fmt.Errorf("service group not found: %s", groupName)
}
var hook string
if isBeforeHook {
hook = serviceGroup.Hooks.BeforeHook
} else {
hook = serviceGroup.Hooks.AfterHook
}
if hook == "" {
return nil
}
// Execute the hook
cmd := exec.Command("sh", "-c", hook)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("hook execution failed: %w, output: %s", err, string(output))
}
log.Printf("Hook executed successfully: %s, output: %s", hook, string(output))
return nil
}
// ReloadConfig refreshes the configuration from the config file
func (f *BackupFactory) ReloadConfig() error {
config, err := LoadConfig(f.ConfigPath)
if err != nil {
return fmt.Errorf("could not reload config: %w", err)
}
f.Config = config
return nil
}

View File

@ -1,51 +0,0 @@
package backup
import (
"context"
"fmt"
"log"
"os"
"os/exec"
)
// RsyncStrategy implements the backup strategy using rsync
type RsyncStrategy struct {
Options string
Destination Destination
}
// NewRsyncStrategy creates a new rsync strategy
func NewRsyncStrategy(options string, destination Destination) *RsyncStrategy {
return &RsyncStrategy{
Options: options,
Destination: destination,
}
}
// Execute performs the rsync backup
func (s *RsyncStrategy) Execute(ctx context.Context, serviceName, directory string) error {
log.Printf("Performing rsync backup for %s", serviceName)
// Build rsync command
dst := s.Destination
sshKeyOption := ""
// Without
if dst.SSHKey != "" {
sshKeyOption = fmt.Sprintf("-e 'ssh -i %s'", dst.SSHKey)
}
destination := fmt.Sprintf("%s:%s", dst.Host, dst.Path)
rsyncCmd := fmt.Sprintf("rsync %s %s %s %s",
s.Options,
sshKeyOption,
directory,
destination)
// Run rsync command
log.Printf("Running: %s", rsyncCmd)
cmd := exec.CommandContext(ctx, "sh", "-c", rsyncCmd)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}

View File

@ -1,112 +0,0 @@
package backup
import (
"log"
"github.com/joho/godotenv"
"github.com/spf13/viper"
)
// Configuration holds the complete application configuration
type Configuration struct {
Defaults DefaultsConfig `mapstructure:"defaults"`
Services map[string]ServiceGroup `mapstructure:"services"`
}
// DefaultsConfig holds the default settings for all services
type DefaultsConfig struct {
Retention RetentionConfig `mapstructure:"retention"`
}
// ServiceGroup represents a service group with a common directory and multiple backup configurations
type ServiceGroup struct {
Directory string `mapstructure:"directory,omitempty"`
Source SourceConfig `mapstructure:"source"`
Hooks HooksConfig `mapstructure:"hooks"`
BackupConfigs map[string]BackupConfig `mapstructure:"backup_configs"`
}
// SourceConfig represents source configuration for backups
type SourceConfig struct {
Host string `mapstructure:"host"`
Path string `mapstructure:"path"`
SSHKey string `mapstructure:"ssh_key"`
}
// HooksConfig contains the hooks to run before and after the backup
type HooksConfig struct {
BeforeHook string `mapstructure:"before_hook"`
AfterHook string `mapstructure:"after_hook"`
}
// BackupConfig represents a specific backup configuration
type BackupConfig struct {
BackupStrategy StrategyConfig `mapstructure:"backup_strategy"`
}
// StrategyConfig represents a backup strategy configuration
type StrategyConfig struct {
Type string `mapstructure:"type"`
Provider string `mapstructure:"provider"`
Options string `mapstructure:"options,omitempty"`
Retention RetentionConfig `mapstructure:"retention,omitempty"`
Destination DestConfig `mapstructure:"destination"`
}
// RetentionConfig represents retention policy configuration
type RetentionConfig struct {
KeepLatest int `mapstructure:"keep_latest"`
KeepHourly int `mapstructure:"keep_hourly"`
KeepDaily int `mapstructure:"keep_daily"`
KeepWeekly int `mapstructure:"keep_weekly"`
KeepMonthly int `mapstructure:"keep_monthly"`
KeepYearly int `mapstructure:"keep_yearly"`
}
// DestConfig represents destination configuration for remote backups
type DestConfig struct {
Host string `mapstructure:"host,omitempty"`
Path string `mapstructure:"path"`
SSHKey string `mapstructure:"ssh_key,omitempty"`
}
// LoadConfig loads the application configuration from a file
func LoadConfig(configPath string) (*Configuration, error) {
if err := godotenv.Load(); err != nil {
log.Printf("Warning: Error loading .env file: %v", err)
}
viper.SetConfigFile(configPath)
if err := viper.ReadInConfig(); err != nil {
return nil, err
}
var config Configuration
if err := viper.Unmarshal(&config); err != nil {
return nil, err
}
// Apply default retention settings where needed
for serviceName, service := range config.Services {
for configID, backupConfig := range service.BackupConfigs {
// If retention is not set, use defaults
if isRetentionEmpty(backupConfig.BackupStrategy.Retention) {
backupConfig.BackupStrategy.Retention = config.Defaults.Retention
service.BackupConfigs[configID] = backupConfig
}
}
config.Services[serviceName] = service
}
return &config, nil
}
// isRetentionEmpty checks if a retention config is empty (all zeros)
func isRetentionEmpty(retention RetentionConfig) bool {
return retention.KeepLatest == 0 &&
retention.KeepHourly == 0 &&
retention.KeepDaily == 0 &&
retention.KeepWeekly == 0 &&
retention.KeepMonthly == 0 &&
retention.KeepYearly == 0
}

View File

@ -0,0 +1,176 @@
package core
import (
"backea/internal/backup/config"
"backea/internal/backup/strategy"
"backea/internal/mail"
"context"
"fmt"
"log"
"sync"
)
// Executor handles the execution of backups for multiple services
type Executor struct {
Config *config.Configuration
Factory strategy.Factory
Mailer *mail.Mailer
concurrency int
}
// NewExecutor creates a new backup executor
func NewExecutor(config *config.Configuration, factory strategy.Factory) *Executor {
return &Executor{
Config: config,
Factory: factory,
// Mailer: mailer,
concurrency: 5,
}
}
// SetConcurrency sets the maximum number of concurrent backups
func (e *Executor) SetConcurrency(n int) {
if n > 0 {
e.concurrency = n
}
}
// PerformBackups executes backups for multiple services based on configuration
func (e *Executor) PerformBackups(ctx context.Context, serviceName string, serviceIndex string) error {
// Process services based on parameters
if serviceName != "" {
// Process single service group or specific service
if serviceIndex != "" {
// Process specific service within a group
return e.processSpecificService(ctx, serviceName, serviceIndex)
} else {
// Process all services in the specified group
return e.processServiceGroup(ctx, serviceName)
}
} else {
// Process all service groups in parallel
return e.processAllServiceGroups(ctx)
}
}
// processAllServiceGroups processes all service groups in parallel
func (e *Executor) processAllServiceGroups(ctx context.Context) error {
var wg sync.WaitGroup
errs := make(chan error, len(e.Config.Services))
// Create a semaphore to limit concurrency
sem := make(chan struct{}, e.concurrency)
for groupName := range e.Config.Services {
wg.Add(1)
sem <- struct{}{} // Acquire semaphore
go func(group string) {
defer wg.Done()
defer func() { <-sem }() // Release semaphore
if err := e.processServiceGroup(ctx, group); err != nil {
log.Printf("Failed to backup service group %s: %v", group, err)
errs <- fmt.Errorf("backup failed for group %s: %w", group, err)
}
}(groupName)
}
// Wait for all backups to complete
wg.Wait()
close(errs)
// Check if any errors occurred and return the last one
var lastErr error
for err := range errs {
lastErr = err
}
return lastErr
}
// processServiceGroup handles the backup for all services in a group
func (e *Executor) processServiceGroup(ctx context.Context, groupName string) error {
// Get service group configuration
serviceGroup, exists := e.Config.Services[groupName]
if !exists {
log.Printf("Service group not found: %s", groupName)
return fmt.Errorf("service group not found: %s", groupName)
}
// Create hook runner
hooks := NewHookRunner(serviceGroup.Source.Path, serviceGroup.Hooks.BeforeHook, serviceGroup.Hooks.AfterHook)
// Execute the before hook once for the entire group
if err := hooks.RunBeforeHook(); err != nil {
log.Printf("Failed to execute before hook for group %s: %v", groupName, err)
return fmt.Errorf("before hook failed: %w", err)
}
// Process all services in the group in parallel
var wg sync.WaitGroup
errs := make(chan error, len(serviceGroup.BackupConfigs))
// Create a semaphore to limit concurrency within the group
sem := make(chan struct{}, e.concurrency)
for configIndex := range serviceGroup.BackupConfigs {
wg.Add(1)
sem <- struct{}{} // Acquire semaphore
go func(group, index string) {
defer wg.Done()
defer func() { <-sem }() // Release semaphore
if err := e.processSpecificService(ctx, group, index); err != nil {
log.Printf("Failed to backup service %s.%s: %v", group, index, err)
errs <- fmt.Errorf("backup failed for %s.%s: %w", group, index, err)
}
}(groupName, configIndex)
}
// Wait for all backups to complete
wg.Wait()
close(errs)
// Execute the after hook once for the entire group
if err := hooks.RunAfterHook(); err != nil {
log.Printf("Failed to execute after hook for group %s: %v", groupName, err)
// Don't return here because we want to report backup errors as well
}
// Check if any errors occurred
var lastErr error
for err := range errs {
lastErr = err
}
return lastErr
}
// processSpecificService handles the backup for a specific service in a group
func (e *Executor) processSpecificService(ctx context.Context, groupName string, configIndex string) error {
// Get service configuration
serviceGroup, exists := e.Config.Services[groupName]
if !exists {
return fmt.Errorf("service group not found: %s", groupName)
}
// Check if the config index exists
_, exists = serviceGroup.BackupConfigs[configIndex]
if !exists {
return fmt.Errorf("service index not found: %s.%s", groupName, configIndex)
}
// Create the appropriate backup strategy using the factory
backupStrategy, err := e.Factory.CreateBackupStrategyForService(groupName, configIndex)
if err != nil {
log.Printf("Failed to create backup strategy for service %s.%s: %v", groupName, configIndex, err)
return fmt.Errorf("failed to create backup strategy: %w", err)
}
// Create and run service
service := NewService(
fmt.Sprintf("%s.%s", groupName, configIndex),
serviceGroup.Source.Path,
backupStrategy,
e.Mailer,
)
return service.Backup(ctx)
}

View File

@ -0,0 +1,56 @@
package core
import (
"log"
"os"
"os/exec"
)
// HookRunner runs hooks before and after backups
type HookRunner struct {
Directory string
BeforeHook string
AfterHook string
}
// NewHookRunner creates a new hook runner
func NewHookRunner(directory, beforeHook, afterHook string) *HookRunner {
return &HookRunner{
Directory: directory,
BeforeHook: beforeHook,
AfterHook: afterHook,
}
}
// RunBeforeHook executes the before hook if defined
func (h *HookRunner) RunBeforeHook() error {
if h.BeforeHook == "" {
return nil
}
log.Printf("Running before hook: %s", h.BeforeHook)
return h.runCommand(h.BeforeHook)
}
// RunAfterHook executes the after hook if defined
func (h *HookRunner) RunAfterHook() error {
if h.AfterHook == "" {
return nil
}
log.Printf("Running after hook: %s", h.AfterHook)
return h.runCommand(h.AfterHook)
}
// runCommand executes a shell command in the specified directory
func (h *HookRunner) runCommand(command string) error {
cmd := exec.Command("sh", "-c", command)
if h.Directory != "" {
cmd.Dir = h.Directory
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
log.Printf("Executing command in %s: %s", h.Directory, command)
return cmd.Run()
}

View File

@ -1,12 +1,13 @@
package backup
package core
import (
"backea/internal/backup/models"
"backea/internal/backup/strategy"
"backea/internal/mail"
"context"
"fmt"
"log"
"os"
"os/exec"
"time"
)
@ -14,12 +15,12 @@ import (
type Service struct {
Name string
Directory string
Strategy Strategy
Strategy strategy.Strategy
Mailer *mail.Mailer
}
// NewService creates a new backup service
func NewService(name string, directory string, strategy Strategy, mailer *mail.Mailer) *Service {
func NewService(name string, directory string, strategy strategy.Strategy, mailer *mail.Mailer) *Service {
return &Service{
Name: name,
Directory: directory,
@ -58,14 +59,18 @@ func (s *Service) Backup(ctx context.Context) error {
return nil
}
// RunCommand executes a shell command in the specified directory
// This is now a package function rather than a method on Service
func RunCommand(dir, command string) error {
cmd := exec.Command("sh", "-c", command)
if dir != "" {
cmd.Dir = dir
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
// GetBackupInfos retrieves information about existing backups
func (s *Service) GetBackupInfos(ctx context.Context) ([]models.BackupInfo, error) {
return s.Strategy.ListBackups(ctx, s.Name)
}
// GetStorageUsage retrieves information about storage usage
func (s *Service) GetStorageUsage(ctx context.Context) (*models.StorageUsageInfo, error) {
return s.Strategy.GetStorageUsage(ctx, s.Name)
}
// RestoreBackup restores a backup by ID
func (s *Service) RestoreBackup(ctx context.Context, backupID string) error {
log.Printf("Restoring backup %s for service %s", backupID, s.Name)
return s.Strategy.RestoreBackup(ctx, backupID, s.Name)
}

View File

@ -1,277 +0,0 @@
package backup
import (
b2_client "backea/internal/client"
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
)
// KopiaProvider defines the interface for different Kopia storage backends
type KopiaProvider interface {
// Connect connects to an existing repository or creates a new one
Connect(ctx context.Context, serviceName string, password string, configPath string) error
// GetRepositoryParams returns the parameters needed for repository operations
GetRepositoryParams(serviceName string) ([]string, error)
// GetBucketName returns the storage identifier (bucket name or path)
GetBucketName(serviceName string) string
// GetProviderType returns a string identifying the provider type
GetProviderType() string
}
// KopiaB2Provider implements the KopiaProvider interface for Backblaze B2
type KopiaB2Provider struct{}
// NewKopiaB2Provider creates a new B2 provider
func NewKopiaB2Provider() *KopiaB2Provider {
return &KopiaB2Provider{}
}
// Connect connects to a B2 repository
func (p *KopiaB2Provider) Connect(ctx context.Context, serviceName string, password string, configPath string) error {
// Load environment variables for B2 credentials
keyID := os.Getenv("B2_KEY_ID")
applicationKey := os.Getenv("B2_APPLICATION_KEY")
if keyID == "" || applicationKey == "" {
return fmt.Errorf("B2_KEY_ID and B2_APPLICATION_KEY must be set in .env file")
}
bucketName := p.GetBucketName(serviceName)
// Create B2 client and check if bucket exists, create if not
B2Client, _ := b2_client.NewClientFromEnv()
if B2Client == nil {
return fmt.Errorf("B2 client not initialized")
}
_, err := B2Client.GetBucket(ctx, bucketName)
if err != nil {
log.Printf("Bucket %s not found, creating...", bucketName)
_, err = B2Client.CreateBucket(ctx, bucketName, false)
if err != nil {
return fmt.Errorf("failed to create bucket: %w", err)
}
log.Printf("Created bucket: %s", bucketName)
}
// Try to connect to existing repository with config file
connectCmd := exec.CommandContext(
ctx,
"kopia",
"--config-file", configPath,
"repository", "connect", "b2",
"--bucket", bucketName,
"--key-id", keyID,
"--key", applicationKey,
"--password", password,
)
err = connectCmd.Run()
if err != nil {
// Connection failed, create new repository
log.Printf("Creating new B2 repository for %s", serviceName)
createCmd := exec.CommandContext(
ctx,
"kopia",
"--config-file", configPath,
"repository", "create", "b2",
"--bucket", bucketName,
"--key-id", keyID,
"--key", applicationKey,
"--password", password,
)
createOutput, err := createCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to create repository: %w\nOutput: %s", err, createOutput)
}
}
return nil
}
// GetRepositoryParams returns parameters for B2 operations
func (p *KopiaB2Provider) GetRepositoryParams(serviceName string) ([]string, error) {
keyID := os.Getenv("B2_KEY_ID")
applicationKey := os.Getenv("B2_APPLICATION_KEY")
if keyID == "" || applicationKey == "" {
return nil, fmt.Errorf("B2_KEY_ID and B2_APPLICATION_KEY must be set in .env file")
}
bucketName := p.GetBucketName(serviceName)
return []string{
"b2",
"--bucket", bucketName,
"--key-id", keyID,
"--key", applicationKey,
}, nil
}
// Modify the GetBucketName method in your KopiaB2Provider
func (p *KopiaB2Provider) GetBucketName(serviceName string) string {
// Backblaze dont allow .
sanitized := strings.ReplaceAll(serviceName, ".", "-")
// Add a prefix to make the bucket name unique
return fmt.Sprintf("backea-%s", sanitized)
}
// GetProviderType returns the provider type identifier
func (p *KopiaB2Provider) GetProviderType() string {
return "b2"
}
// KopiaLocalProvider implements the KopiaProvider interface for local storage
type KopiaLocalProvider struct {
BasePath string
}
// NewKopiaLocalProvider creates a new local provider
func NewKopiaLocalProvider(basePath string) *KopiaLocalProvider {
// If basePath is empty, use a default location
if basePath == "" {
basePath = filepath.Join(os.Getenv("HOME"), ".backea", "repos")
}
return &KopiaLocalProvider{
BasePath: basePath,
}
}
// Connect connects to a local repository
func (p *KopiaLocalProvider) Connect(ctx context.Context, serviceName string, password string, configPath string) error {
repoPath := filepath.Join(p.BasePath, serviceName)
log.Printf("Connecting to local repository at %s with config: %s", repoPath, configPath)
// Ensure the directory exists
if err := os.MkdirAll(repoPath, 0755); err != nil {
return fmt.Errorf("failed to create repository directory: %w", err)
}
// Try to connect to existing repository with config file
connectCmd := exec.CommandContext(
ctx,
"kopia",
"--config-file", configPath,
"repository", "connect", "filesystem",
"--path", repoPath,
"--password", password,
)
err := connectCmd.Run()
if err != nil {
// Connection failed, create new repository
log.Printf("Creating new local repository for %s at destination: %s", serviceName, repoPath)
createCmd := exec.CommandContext(
ctx,
"kopia",
"--config-file", configPath,
"repository", "create", "filesystem",
"--path", repoPath,
"--password", password,
)
createOutput, err := createCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to create repository: %w\nOutput: %s", err, createOutput)
}
}
return nil
}
// GetRepositoryParams returns parameters for local filesystem operations
func (p *KopiaLocalProvider) GetRepositoryParams(serviceName string) ([]string, error) {
repoPath := filepath.Join(p.BasePath, serviceName)
return []string{
"filesystem",
"--path", repoPath,
}, nil
}
// GetBucketName returns the path for a service
func (p *KopiaLocalProvider) GetBucketName(serviceName string) string {
return filepath.Join(p.BasePath, serviceName)
}
// GetProviderType returns the provider type identifier
func (p *KopiaLocalProvider) GetProviderType() string {
return "local"
}
// KopiaSFTPProvider implements the KopiaProvider interface for SFTP remote storage
type KopiaSFTPProvider struct {
Host string
BasePath string
Username string
KeyFile string
}
// NewKopiaSFTPProvider creates a new SFTP provider
func NewKopiaSFTPProvider(host, basePath, username, keyFile string) *KopiaSFTPProvider {
return &KopiaSFTPProvider{
Host: host,
BasePath: basePath,
Username: username,
KeyFile: keyFile,
}
}
// Connect connects to an SFTP repository
func (p *KopiaSFTPProvider) Connect(ctx context.Context, serviceName string, password string, configPath string) error {
repoPath := fmt.Sprintf("%s@%s:%s/%s", p.Username, p.Host, p.BasePath, serviceName)
// Try to connect to existing repository with config file
connectCmd := exec.CommandContext(
ctx,
"kopia",
"--config-file", configPath,
"repository", "connect", "sftp",
"--path", repoPath,
"--keyfile", p.KeyFile,
"--known-hosts", filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts"),
"--password", password,
)
err := connectCmd.Run()
if err != nil {
// Connection failed, create new repository
log.Printf("Creating new SFTP repository for %s at %s", serviceName, repoPath)
createCmd := exec.CommandContext(
ctx,
"kopia",
"--config-file", configPath,
"repository", "create", "sftp",
"--path", repoPath,
"--keyfile", p.KeyFile,
"--known-hosts", filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts"),
"--password", password,
)
createOutput, err := createCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to create repository: %w\nOutput: %s", err, createOutput)
}
}
return nil
}
// GetRepositoryParams returns parameters for SFTP operations
func (p *KopiaSFTPProvider) GetRepositoryParams(serviceName string) ([]string, error) {
repoPath := fmt.Sprintf("%s@%s:%s/%s", p.Username, p.Host, p.BasePath, serviceName)
return []string{
"sftp",
"--path", repoPath,
"--keyfile", p.KeyFile,
"--known-hosts", filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts"),
}, nil
}
// GetBucketName returns the path for a service
func (p *KopiaSFTPProvider) GetBucketName(serviceName string) string {
return fmt.Sprintf("%s/%s", p.BasePath, serviceName)
}
// GetProviderType returns the provider type identifier
func (p *KopiaSFTPProvider) GetProviderType() string {
return "sftp"
}

View File

@ -0,0 +1,15 @@
package models
import (
"time"
)
// BackupInfo contains information about a single backup
type BackupInfo struct {
ID string // Unique identifier for the backup
CreationTime time.Time // When the backup was created
Size int64 // Size in bytes
Source string // Original source path
Type string // Type of backup (e.g., "kopia", "restic")
RetentionTag string // Retention policy applied (e.g., "latest", "daily", "weekly")
}

View File

@ -0,0 +1,8 @@
package models
// StorageUsageInfo contains information about backup storage usage
type StorageUsageInfo struct {
TotalBytes int64 // Total bytes stored
Provider string // Storage provider (e.g., "local", "b2", "s3")
ProviderID string // Provider-specific ID (bucket name, path, etc.)
}

View File

@ -1,179 +0,0 @@
package backup
import (
client "backea/internal/client"
"bufio"
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
)
// RestoreManager handles restoring files from Kopia backups
type RestoreManager struct {
B2Client *client.Client
}
// NewRestoreManager creates a new restore manager
func NewRestoreManager(b2Client *client.Client) *RestoreManager {
return &RestoreManager{
B2Client: b2Client,
}
}
// getStoredPassword retrieves a password from kopia.env file
func (r *RestoreManager) getStoredPassword(serviceName string) (string, error) {
// Define the expected key in the env file
passwordKey := fmt.Sprintf("KOPIA_%s_PASSWORD", strings.ToUpper(serviceName))
// Try to read from kopia.env
kopiaEnvPath := "kopia.env"
if _, err := os.Stat(kopiaEnvPath); err != nil {
return "", fmt.Errorf("kopia.env file not found: %w", err)
}
// File exists, check if the password is there
file, err := os.Open(kopiaEnvPath)
if err != nil {
return "", fmt.Errorf("failed to open kopia.env: %w", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, passwordKey+"=") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
// Found the password, remove quotes if present
password := parts[1]
password = strings.Trim(password, "\"")
return password, nil
}
}
}
if err := scanner.Err(); err != nil {
return "", fmt.Errorf("error reading kopia.env: %w", err)
}
return "", fmt.Errorf("password for service %s not found in kopia.env", serviceName)
}
// connectToRepository connects to an existing Kopia repository
func (r *RestoreManager) connectToRepository(ctx context.Context, serviceName string) error {
if r.B2Client == nil {
return fmt.Errorf("B2 client not initialized")
}
// Load environment variables for B2 credentials
keyID := os.Getenv("B2_KEY_ID")
applicationKey := os.Getenv("B2_APPLICATION_KEY")
if keyID == "" || applicationKey == "" {
return fmt.Errorf("B2_KEY_ID and B2_APPLICATION_KEY must be set in .env file")
}
// Get stored password for this service
password, err := r.getStoredPassword(serviceName)
if err != nil {
return fmt.Errorf("failed to get stored password: %w", err)
}
// Generate bucket name from service name
bucketName := fmt.Sprintf("backea-%s", strings.ToLower(serviceName))
// Check if bucket exists
_, err = r.B2Client.GetBucket(ctx, bucketName)
if err != nil {
return fmt.Errorf("bucket %s not found: %w", bucketName, err)
}
// Check if kopia repository is already connected
cmd := exec.Command("kopia", "repository", "status")
err = cmd.Run()
if err == nil {
// Already connected
log.Printf("Already connected to a repository, disconnecting first")
disconnectCmd := exec.Command("kopia", "repository", "disconnect")
if err := disconnectCmd.Run(); err != nil {
return fmt.Errorf("failed to disconnect from current repository: %w", err)
}
}
// Connect to the repository
log.Printf("Connecting to B2 repository for %s", serviceName)
connectCmd := exec.Command(
"kopia", "repository", "connect", "b2",
"--bucket", bucketName,
"--key-id", keyID,
"--key", applicationKey,
"--password", password,
)
connectOutput, err := connectCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to connect to repository: %w\nOutput: %s", err, connectOutput)
}
log.Printf("Successfully connected to repository for %s", serviceName)
return nil
}
// ListSnapshots lists all snapshots in the repository for a given service
func (r *RestoreManager) ListSnapshots(ctx context.Context, serviceName string) error {
// Connect to the repository
if err := r.connectToRepository(ctx, serviceName); err != nil {
return err
}
// List all snapshots
log.Printf("Listing snapshots for %s", serviceName)
listCmd := exec.Command("kopia", "snapshot", "list")
listOutput, err := listCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to list snapshots: %w\nOutput: %s", err, listOutput)
}
// Print the output
fmt.Println("Available snapshots:")
fmt.Println(string(listOutput))
return nil
}
// RestoreFile restores a file or directory from a specific snapshot to a target location
func (r *RestoreManager) RestoreFile(ctx context.Context, serviceName, snapshotID, sourcePath, targetPath string) error {
// Connect to the repository
if err := r.connectToRepository(ctx, serviceName); err != nil {
return err
}
// Create a subdirectory with the snapshot ID name
snapshotDirPath := filepath.Join(targetPath, snapshotID)
if err := os.MkdirAll(snapshotDirPath, 0755); err != nil {
return fmt.Errorf("failed to create target directory: %w", err)
}
// Construct the kopia restore command
log.Printf("Restoring from snapshot %s to %s", snapshotID, snapshotDirPath)
// Build the command with all required parameters
restoreCmd := exec.Command(
"kopia", "snapshot", "restore",
snapshotID, // Just the snapshot ID
snapshotDirPath, // Target location where files will be restored
)
// Execute the command and capture output
restoreOutput, err := restoreCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to restore files: %w\nOutput: %s", err, restoreOutput)
}
log.Printf("Successfully restored files to %s", snapshotDirPath)
log.Printf("Restore output: %s", string(restoreOutput))
return nil
}

View File

@ -0,0 +1,115 @@
package security
import (
"bufio"
"crypto/rand"
"fmt"
"log"
"os"
"strings"
)
// GetOrCreatePassword retrieves a password from kopia.env or creates a new one if it doesn't exist
func GetOrCreatePassword(serviceName string, length int) (string, error) {
// Define the expected key in the env file
// Replace dots with underscores for environment variable name
safeServiceName := strings.ReplaceAll(serviceName, ".", "_")
passwordKey := fmt.Sprintf("KOPIA_%s_PASSWORD", strings.ToUpper(safeServiceName))
// Try to read from kopia.env first
kopiaEnvPath := "kopia.env"
if _, err := os.Stat(kopiaEnvPath); err == nil {
// File exists, check if the password is already there
file, err := os.Open(kopiaEnvPath)
if err != nil {
return "", fmt.Errorf("failed to open kopia.env: %w", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, passwordKey+"=") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
// Found the password, remove quotes if present
password := parts[1]
password = strings.Trim(password, "\"")
return password, nil
}
}
}
if err := scanner.Err(); err != nil {
return "", fmt.Errorf("error reading kopia.env: %w", err)
}
}
// Password not found or file doesn't exist, generate a new password
password, err := GenerateSecurePassword(length)
if err != nil {
return "", fmt.Errorf("failed to generate password: %w", err)
}
// Create or append to kopia.env
file, err := os.OpenFile(kopiaEnvPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return "", fmt.Errorf("failed to open kopia.env for writing: %w", err)
}
defer file.Close()
// Write the new password entry with quotes
passwordEntry := fmt.Sprintf("%s=\"%s\"\n", passwordKey, password)
if _, err := file.WriteString(passwordEntry); err != nil {
return "", fmt.Errorf("failed to write to kopia.env: %w", err)
}
log.Printf("Created new password for service %s and stored in kopia.env", serviceName)
return password, nil
}
// GenerateSecurePassword creates a cryptographically secure random password
// using only safe characters that work well with command line tools
func GenerateSecurePassword(length int) (string, error) {
// Use a more robust but safe character set
// Avoiding characters that might cause shell interpretation issues
// No quotes, backslashes, spaces, or common special chars that need escaping
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
result := make([]byte, length)
// Create a secure source of randomness
randomBytes := make([]byte, length)
if _, err := rand.Read(randomBytes); err != nil {
return "", err
}
// Ensure minimum complexity requirements
// At least 2 uppercase, 2 lowercase, 2 digits
if length >= 6 {
// Force first characters to meet minimum requirements
result[0] = 'A' + byte(randomBytes[0]%26) // Uppercase
result[1] = 'A' + byte(randomBytes[1]%26) // Uppercase
result[2] = 'a' + byte(randomBytes[2]%26) // Lowercase
result[3] = 'a' + byte(randomBytes[3]%26) // Lowercase
result[4] = '0' + byte(randomBytes[4]%10) // Digit
result[5] = '0' + byte(randomBytes[5]%10) // Digit
// Fill the rest with random chars
for i := 6; i < length; i++ {
result[i] = chars[int(randomBytes[i])%len(chars)]
}
// Shuffle the result to avoid predictable pattern
for i := length - 1; i > 0; i-- {
j := int(randomBytes[i]) % (i + 1)
result[i], result[j] = result[j], result[i]
}
} else {
// For very short passwords just use random chars
for i := 0; i < length; i++ {
result[i] = chars[int(randomBytes[i])%len(chars)]
}
}
return string(result), nil
}

View File

@ -1,57 +0,0 @@
package backup
import (
"context"
"io"
"time"
)
// Strategy defines the backup/restore strategy interface
type Strategy interface {
// Execute performs a backup
Execute(ctx context.Context, serviceName, directory string) error
ListBackups(ctx context.Context, serviceName string) ([]BackupInfo, error)
GetStorageUsage(ctx context.Context, serviceName string) (*StorageUsageInfo, error)
RestoreBackup(ctx context.Context, backupID string, serviceName string) error
DownloadBackup(ctx context.Context, backupID, serviceName string) (io.ReadCloser, error)
GetBackupInfo(context.Context, string, string) (*BackupInfo, error)
}
// BackupInfo contains information about a single backup
type BackupInfo struct {
ID string // Unique identifier for the backup
CreationTime time.Time // When the backup was created
Size int64 // Size in bytes
Source string // Original source path
Type string // Type of backup (e.g., "kopia", "restic")
RetentionTag string // Retention policy applied (e.g., "latest", "daily", "weekly")
}
// StorageUsageInfo contains information about backup storage usage
type StorageUsageInfo struct {
TotalBytes int64 // Total bytes stored
Provider string // Storage provider (e.g., "local", "b2", "s3")
ProviderID string // Provider-specific ID (bucket name, path, etc.)
}
// Retention represents retention policy for backups
type Retention struct {
KeepLatest int
KeepHourly int
KeepDaily int
KeepWeekly int
KeepMonthly int
KeepYearly int
}
// Destination represents rsync destination
type Destination struct {
Host string
Path string
SSHKey string
}

View File

@ -0,0 +1,150 @@
package strategy
import (
"backea/internal/backup/config"
"backea/internal/backup/strategy/kopia"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// Factory handles backup strategy creation
type Factory struct {
Config *config.Configuration
ConfigPath string
}
// NewFactory initializes and returns a Factory
func NewFactory(configPath string) (*Factory, error) {
cfg, err := config.LoadConfig(configPath)
if err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}
return &Factory{Config: cfg, ConfigPath: configPath}, nil
}
// CreateBackupStrategyForService returns a backup strategy based on config
func (f *Factory) CreateBackupStrategyForService(groupName, serviceIndex string) (Strategy, error) {
serviceConfig, err := f.getServiceConfig(groupName, serviceIndex)
if err != nil {
return nil, err
}
sourcePath, err := f.getServicePath(groupName)
if err != nil {
return nil, err
}
switch serviceConfig.BackupStrategy.Type {
case "kopia":
return f.createKopiaStrategy(serviceConfig, sourcePath)
default:
return nil, fmt.Errorf("unknown strategy type: %s", serviceConfig.BackupStrategy.Type)
}
}
// createKopiaStrategy initializes a Kopia strategy
func (f *Factory) createKopiaStrategy(serviceConfig *config.BackupConfig, sourcePath string) (Strategy, error) {
provider, err := f.createKopiaProvider(serviceConfig.BackupStrategy)
if err != nil {
return nil, fmt.Errorf("failed to create kopia provider: %w", err)
}
retention := kopia.Retention(serviceConfig.BackupStrategy.Retention)
configPath := generateTempConfigPath(provider.GetProviderType())
return kopia.NewStrategy(retention, provider, configPath, sourcePath), nil
}
// createKopiaProvider returns a Kopia storage provider
func (f *Factory) createKopiaProvider(strategyConfig config.StrategyConfig) (kopia.Provider, error) {
switch strategyConfig.Provider {
case "local":
return kopia.NewLocalProvider(getDefaultPath(strategyConfig.Destination.Path)), nil
case "b2", "backblaze":
return kopia.NewB2Provider(), nil
// case "sftp":
// return createSFTPProvider(strategyConfig.Destination)
default:
return nil, fmt.Errorf("unknown kopia provider type: %s", strategyConfig.Provider)
}
}
// createSFTPProvider handles SFTP provider creation
func createSFTPProvider(dest DestinationConfig) (kopia.Provider, error) {
host, username := parseSFTPHost(dest.Host)
if host == "" {
return nil, fmt.Errorf("SFTP host not specified")
}
return kopia.NewSFTPProvider(host, getDefaultPath(dest.Path), username, getSSHKeyPath(dest.SSHKey)), nil
}
// parseSFTPHost extracts the username and host from "user@host"
func parseSFTPHost(host string) (string, string) {
if atIndex := strings.Index(host, "@"); atIndex > 0 {
return host[atIndex+1:], host[:atIndex]
}
return host, getEnvUser()
}
// getDefaultPath returns the given path or a default
func getDefaultPath(path string) string {
if path != "" {
return path
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".backea", "repos")
}
// getEnvUser returns the system username
func getEnvUser() string {
if user := os.Getenv("USER"); user != "" {
return user
}
return "root"
}
// getSSHKeyPath returns the SSH key path
func getSSHKeyPath(key string) string {
if key != "" {
return key
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".ssh", "id_rsa")
}
// getServicePath returns a service's path
func (f *Factory) getServicePath(groupName string) (string, error) {
if serviceGroup, exists := f.Config.Services[groupName]; exists {
return serviceGroup.Source.Path, nil
}
return "", fmt.Errorf("service group not found: %s", groupName)
}
// getServiceConfig returns a service's config
func (f *Factory) getServiceConfig(groupName, serviceIndex string) (*config.BackupConfig, error) {
if serviceGroup, exists := f.Config.Services[groupName]; exists {
if backupConfig, exists := serviceGroup.BackupConfigs[serviceIndex]; exists {
return &backupConfig, nil
}
}
return nil, fmt.Errorf("backup config not found: %s.%s", groupName, serviceIndex)
}
// ReloadConfig reloads configuration
func (f *Factory) ReloadConfig() error {
cfg, err := config.LoadConfig(f.ConfigPath)
if err != nil {
return fmt.Errorf("could not reload config: %w", err)
}
f.Config = cfg
return nil
}
// generateTempConfigPath creates a temp Kopia config path
func generateTempConfigPath(providerType string) string {
tmpConfigDir := filepath.Join(os.TempDir(), "kopia_configs")
_ = os.MkdirAll(tmpConfigDir, 0755) // Ensure directory exists
return filepath.Join(tmpConfigDir, fmt.Sprintf("kopia_%s_%d.config", providerType, time.Now().UnixNano()))
}

View File

@ -0,0 +1,113 @@
package kopia
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"strings"
b2_client "backea/internal/client"
)
// B2Provider implements the Provider interface for Backblaze B2
type B2Provider struct{}
// NewB2Provider creates a new B2 provider
func NewB2Provider() *B2Provider {
return &B2Provider{}
}
// Connect connects to a B2 repository
func (p *B2Provider) Connect(ctx context.Context, serviceName string, password string, configPath string) error {
// Load environment variables for B2 credentials
keyID := os.Getenv("B2_KEY_ID")
applicationKey := os.Getenv("B2_APPLICATION_KEY")
if keyID == "" || applicationKey == "" {
return fmt.Errorf("B2_KEY_ID and B2_APPLICATION_KEY must be set in .env file")
}
bucketName := p.GetBucketName(serviceName)
// Create B2 client and check if bucket exists, create if not
B2Client, _ := b2_client.NewClientFromEnv()
if B2Client == nil {
return fmt.Errorf("B2 client not initialized")
}
_, err := B2Client.GetBucket(ctx, bucketName)
if err != nil {
log.Printf("Bucket %s not found, creating...", bucketName)
_, err = B2Client.CreateBucket(ctx, bucketName, false)
if err != nil {
return fmt.Errorf("failed to create bucket: %w", err)
}
log.Printf("Created bucket: %s", bucketName)
}
// Try to connect to existing repository with config file
connectCmd := exec.CommandContext(
ctx,
"kopia",
"--config-file", configPath,
"repository", "connect", "b2",
"--bucket", bucketName,
"--key-id", keyID,
"--key", applicationKey,
"--password", password,
)
err = connectCmd.Run()
if err != nil {
// Connection failed, create new repository
log.Printf("Creating new B2 repository for %s", serviceName)
createCmd := exec.CommandContext(
ctx,
"kopia",
"--config-file", configPath,
"repository", "create", "b2",
"--bucket", bucketName,
"--key-id", keyID,
"--key", applicationKey,
"--password", password,
)
createOutput, err := createCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to create repository: %w\nOutput: %s", err, createOutput)
}
}
return nil
}
// GetRepositoryParams returns parameters for B2 operations
func (p *B2Provider) GetRepositoryParams(serviceName string) ([]string, error) {
keyID := os.Getenv("B2_KEY_ID")
applicationKey := os.Getenv("B2_APPLICATION_KEY")
if keyID == "" || applicationKey == "" {
return nil, fmt.Errorf("B2_KEY_ID and B2_APPLICATION_KEY must be set in .env file")
}
bucketName := p.GetBucketName(serviceName)
return []string{
"b2",
"--bucket", bucketName,
"--key-id", keyID,
"--key", applicationKey,
}, nil
}
// GetBucketName returns a bucket name for the service
func (p *B2Provider) GetBucketName(serviceName string) string {
// Backblaze doesn't allow dots in bucket names
sanitized := strings.ReplaceAll(serviceName, ".", "-")
// Add a prefix to make the bucket name unique
return fmt.Sprintf("backea-%s", sanitized)
}
// GetProviderType returns the provider type identifier
func (p *B2Provider) GetProviderType() string {
return "b2"
}

View File

@ -0,0 +1,85 @@
package kopia
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
)
// LocalProvider implements the Provider interface for local storage
type LocalProvider struct {
BasePath string
}
// NewLocalProvider creates a new local provider
func NewLocalProvider(basePath string) *LocalProvider {
// If basePath is empty, use a default location
if basePath == "" {
basePath = filepath.Join(os.Getenv("HOME"), ".backea", "repos")
}
return &LocalProvider{
BasePath: basePath,
}
}
// Connect connects to a local repository
func (p *LocalProvider) Connect(ctx context.Context, serviceName string, password string, configPath string) error {
repoPath := filepath.Join(p.BasePath, serviceName)
log.Printf("Connecting to local repository at %s with config: %s", repoPath, configPath)
// Ensure the directory exists
if err := os.MkdirAll(repoPath, 0755); err != nil {
return fmt.Errorf("failed to create repository directory: %w", err)
}
// Try to connect to existing repository with config file
connectCmd := exec.CommandContext(
ctx,
"kopia",
"--config-file", configPath,
"repository", "connect", "filesystem",
"--path", repoPath,
"--password", password,
)
err := connectCmd.Run()
if err != nil {
// Connection failed, create new repository
log.Printf("Creating new local repository for %s at destination: %s", serviceName, repoPath)
createCmd := exec.CommandContext(
ctx,
"kopia",
"--config-file", configPath,
"repository", "create", "filesystem",
"--path", repoPath,
"--password", password,
)
createOutput, err := createCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to create repository: %w\nOutput: %s", err, createOutput)
}
}
return nil
}
// GetRepositoryParams returns parameters for local filesystem operations
func (p *LocalProvider) GetRepositoryParams(serviceName string) ([]string, error) {
repoPath := filepath.Join(p.BasePath, serviceName)
return []string{
"filesystem",
"--path", repoPath,
}, nil
}
// GetBucketName returns the path for a service
func (p *LocalProvider) GetBucketName(serviceName string) string {
return filepath.Join(p.BasePath, serviceName)
}
// GetProviderType returns the provider type identifier
func (p *LocalProvider) GetProviderType() string {
return "local"
}

View File

@ -0,0 +1,20 @@
package kopia
import (
"context"
)
// Provider defines the interface for different Kopia storage backends
type Provider interface {
// Connect connects to an existing repository or creates a new one
Connect(ctx context.Context, serviceName string, password string, configPath string) error
// GetRepositoryParams returns the parameters needed for repository operations
GetRepositoryParams(serviceName string) ([]string, error)
// GetBucketName returns the storage identifier (bucket name or path)
GetBucketName(serviceName string) string
// GetProviderType returns a string identifying the provider type
GetProviderType() string
}

View File

@ -0,0 +1,87 @@
package kopia
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
)
// SFTPProvider implements the Provider interface for SFTP remote storage
type SFTPProvider struct {
Host string
BasePath string
Username string
KeyFile string
}
// NewSFTPProvider creates a new SFTP provider
func NewSFTPProvider(host, basePath, username, keyFile string) *SFTPProvider {
return &SFTPProvider{
Host: host,
BasePath: basePath,
Username: username,
KeyFile: keyFile,
}
}
// Connect connects to an SFTP repository
func (p *SFTPProvider) Connect(ctx context.Context, serviceName string, password string, configPath string) error {
repoPath := fmt.Sprintf("%s@%s:%s/%s", p.Username, p.Host, p.BasePath, serviceName)
// Try to connect to existing repository with config file
connectCmd := exec.CommandContext(
ctx,
"kopia",
"--config-file", configPath,
"repository", "connect", "sftp",
"--path", repoPath,
"--keyfile", p.KeyFile,
"--known-hosts", filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts"),
"--password", password,
)
err := connectCmd.Run()
if err != nil {
// Connection failed, create new repository
log.Printf("Creating new SFTP repository for %s at %s", serviceName, repoPath)
createCmd := exec.CommandContext(
ctx,
"kopia",
"--config-file", configPath,
"repository", "create", "sftp",
"--path", repoPath,
"--keyfile", p.KeyFile,
"--known-hosts", filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts"),
"--password", password,
)
createOutput, err := createCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to create repository: %w\nOutput: %s", err, createOutput)
}
}
return nil
}
// GetRepositoryParams returns parameters for SFTP operations
func (p *SFTPProvider) GetRepositoryParams(serviceName string) ([]string, error) {
repoPath := fmt.Sprintf("%s@%s:%s/%s", p.Username, p.Host, p.BasePath, serviceName)
return []string{
"sftp",
"--path", repoPath,
"--keyfile", p.KeyFile,
"--known-hosts", filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts"),
}, nil
}
// GetBucketName returns the path for a service
func (p *SFTPProvider) GetBucketName(serviceName string) string {
return fmt.Sprintf("%s/%s", p.BasePath, serviceName)
}
// GetProviderType returns the provider type identifier
func (p *SFTPProvider) GetProviderType() string {
return "sftp"
}

View File

@ -1,10 +1,8 @@
package backup
package kopia
import (
"archive/zip"
"bufio"
"context"
"crypto/rand"
"encoding/json"
"fmt"
"io"
@ -15,30 +13,45 @@ import (
"strconv"
"strings"
"time"
"backea/internal/backup/models"
"backea/internal/backup/security"
)
// KopiaStrategy implements the backup strategy using Kopia
type KopiaStrategy struct {
// Strategy implements the backup strategy using Kopia
type Strategy struct {
Retention Retention
Provider KopiaProvider
Provider Provider
ConfigPath string
SourcePath string
}
// NewKopiaStrategy creates a new kopia strategy with specified provider
func NewKopiaStrategy(retention Retention, provider KopiaProvider, configPath string) *KopiaStrategy {
return &KopiaStrategy{
// Retention represents retention policy for backups
type Retention struct {
KeepLatest int
KeepHourly int
KeepDaily int
KeepWeekly int
KeepMonthly int
KeepYearly int
}
// NewStrategy creates a new kopia strategy with specified provider
func NewStrategy(retention Retention, provider Provider, configPath string, sourcePath string) *Strategy {
return &Strategy{
Retention: retention,
Provider: provider,
ConfigPath: configPath,
SourcePath: sourcePath,
}
}
// Execute performs the kopia backup
func (s *KopiaStrategy) Execute(ctx context.Context, serviceName, directory string) error {
func (s *Strategy) Execute(ctx context.Context, serviceName, directory string) error {
log.Printf("Performing kopia backup for service %s: %s", serviceName, directory)
// Get or create password for this service
password, err := getOrCreatePassword(serviceName, 48)
password, err := security.GetOrCreatePassword(serviceName, 48)
if err != nil {
return fmt.Errorf("failed to get or create password: %w", err)
}
@ -87,27 +100,21 @@ func (s *KopiaStrategy) Execute(ctx context.Context, serviceName, directory stri
}
// ListBackups returns information about existing backups
func (s *KopiaStrategy) ListBackups(ctx context.Context, serviceName string) ([]BackupInfo, error) {
// Parse service group and index from service name (e.g., "backealocal.1")
groupName, _, err := parseServiceName(serviceName)
func (s *Strategy) ListBackups(ctx context.Context, serviceName string) ([]models.BackupInfo, error) {
// Parse service group and index from service name
_, _, err := parseServiceName(serviceName)
if err != nil {
return nil, fmt.Errorf("invalid service name format: %w", err)
}
// Get service directory from config
factory, err := NewBackupFactory("config.yml")
if err != nil {
return nil, fmt.Errorf("failed to create factory: %w", err)
// Use the source path from the Strategy instead of trying to get it from the factory
directoryPath := s.SourcePath
if directoryPath == "" {
return nil, fmt.Errorf("source path not specified")
}
// Find service group
serviceGroup, exists := factory.Config.Services[groupName]
if !exists {
return nil, fmt.Errorf("service group not found: %s", groupName)
}
// Get directory from the service group level
directoryPath := strings.TrimRight(serviceGroup.Source.Path, "/")
// Trim trailing slash if any
directoryPath = strings.TrimRight(directoryPath, "/")
// Ensure we're connected to the repository
err = s.EnsureRepositoryConnected(ctx, serviceName)
@ -137,7 +144,7 @@ func (s *KopiaStrategy) ListBackups(ctx context.Context, serviceName string) ([]
}
// Convert to BackupInfo
var result []BackupInfo
var result []models.BackupInfo
for _, snap := range snapshots {
// Get basic info
id, _ := snap["id"].(string)
@ -184,7 +191,7 @@ func (s *KopiaStrategy) ListBackups(ctx context.Context, serviceName string) ([]
}
}
result = append(result, BackupInfo{
result = append(result, models.BackupInfo{
ID: id,
CreationTime: creationTime,
Size: size,
@ -198,7 +205,7 @@ func (s *KopiaStrategy) ListBackups(ctx context.Context, serviceName string) ([]
}
// GetStorageUsage returns information about the total storage used by the repository
func (s *KopiaStrategy) GetStorageUsage(ctx context.Context, serviceName string) (*StorageUsageInfo, error) {
func (s *Strategy) GetStorageUsage(ctx context.Context, serviceName string) (*models.StorageUsageInfo, error) {
// Ensure we're connected to the repository
err := s.EnsureRepositoryConnected(ctx, serviceName)
if err != nil {
@ -207,17 +214,10 @@ func (s *KopiaStrategy) GetStorageUsage(ctx context.Context, serviceName string)
// Get provider type (b2, local, sftp)
providerType := "unknown"
switch s.Provider.(type) {
case *KopiaB2Provider:
providerType = "b2"
case *KopiaLocalProvider:
providerType = "local"
case *KopiaSFTPProvider:
providerType = "sftp"
}
providerType = s.Provider.GetProviderType()
// Initialize storage info
info := &StorageUsageInfo{
info := &models.StorageUsageInfo{
Provider: providerType,
ProviderID: s.Provider.GetBucketName(serviceName),
}
@ -273,195 +273,18 @@ func (s *KopiaStrategy) GetStorageUsage(ctx context.Context, serviceName string)
return info, nil
}
// Helper method to ensure the repository is connected before operations
func (s *KopiaStrategy) EnsureRepositoryConnected(ctx context.Context, serviceName string) error {
// Check if kopia repository is already connected with config file
cmd := exec.Command("kopia", "--config-file", s.ConfigPath, "repository", "status")
err := cmd.Run()
if err != nil {
// Repository not connected, try to connect
password, err := getOrCreatePassword(serviceName, 48)
if err != nil {
return fmt.Errorf("failed to get password: %w", err)
}
// Connect using provider
if err := s.Provider.Connect(ctx, serviceName, password, s.ConfigPath); err != nil {
return fmt.Errorf("failed to connect to repository: %w", err)
}
}
return nil
}
// parseServiceName splits a service name in the format "group.index" into its components
func parseServiceName(serviceName string) (string, string, error) {
parts := strings.SplitN(serviceName, ".", 2)
if len(parts) != 2 {
return "", "", fmt.Errorf("service name must be in format 'group.index', got '%s'", serviceName)
}
return parts[0], parts[1], nil
}
// getOrCreatePassword retrieves a password from kopia.env or creates a new one if it doesn't exist
// Extracted to a package-level function since it's utility functionality
func getOrCreatePassword(serviceName string, length int) (string, error) {
// Define the expected key in the env file
// Replace dots with underscores for environment variable name
safeServiceName := strings.ReplaceAll(serviceName, ".", "_")
passwordKey := fmt.Sprintf("KOPIA_%s_PASSWORD", strings.ToUpper(safeServiceName))
// Try to read from kopia.env first
kopiaEnvPath := "kopia.env"
if _, err := os.Stat(kopiaEnvPath); err == nil {
// File exists, check if the password is already there
file, err := os.Open(kopiaEnvPath)
if err != nil {
return "", fmt.Errorf("failed to open kopia.env: %w", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, passwordKey+"=") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
// Found the password, remove quotes if present
password := parts[1]
password = strings.Trim(password, "\"")
return password, nil
}
}
}
if err := scanner.Err(); err != nil {
return "", fmt.Errorf("error reading kopia.env: %w", err)
}
}
// Password not found or file doesn't exist, generate a new password
password, err := generateSecurePassword(length)
if err != nil {
return "", fmt.Errorf("failed to generate password: %w", err)
}
// Create or append to kopia.env
file, err := os.OpenFile(kopiaEnvPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return "", fmt.Errorf("failed to open kopia.env for writing: %w", err)
}
defer file.Close()
// Write the new password entry with quotes
passwordEntry := fmt.Sprintf("%s=\"%s\"\n", passwordKey, password)
if _, err := file.WriteString(passwordEntry); err != nil {
return "", fmt.Errorf("failed to write to kopia.env: %w", err)
}
log.Printf("Created new password for service %s and stored in kopia.env", serviceName)
return password, nil
}
// generateSecurePassword creates a cryptographically secure random password
// using only safe characters that work well with command line tools
func generateSecurePassword(length int) (string, error) {
// Use a more robust but safe character set
// Avoiding characters that might cause shell interpretation issues
// No quotes, backslashes, spaces, or common special chars that need escaping
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
result := make([]byte, length)
// Create a secure source of randomness
randomBytes := make([]byte, length)
if _, err := rand.Read(randomBytes); err != nil {
return "", err
}
// Ensure minimum complexity requirements
// At least 2 uppercase, 2 lowercase, 2 digits
if length >= 6 {
// Force first characters to meet minimum requirements
result[0] = 'A' + byte(randomBytes[0]%26) // Uppercase
result[1] = 'A' + byte(randomBytes[1]%26) // Uppercase
result[2] = 'a' + byte(randomBytes[2]%26) // Lowercase
result[3] = 'a' + byte(randomBytes[3]%26) // Lowercase
result[4] = '0' + byte(randomBytes[4]%10) // Digit
result[5] = '0' + byte(randomBytes[5]%10) // Digit
// Fill the rest with random chars
for i := 6; i < length; i++ {
result[i] = chars[int(randomBytes[i])%len(chars)]
}
// Shuffle the result to avoid predictable pattern
for i := length - 1; i > 0; i-- {
j := int(randomBytes[i]) % (i + 1)
result[i], result[j] = result[j], result[i]
}
} else {
// For very short passwords just use random chars
for i := 0; i < length; i++ {
result[i] = chars[int(randomBytes[i])%len(chars)]
}
}
return string(result), nil
}
// parseHumanSize parses a human-readable size string (e.g., "32.8 MB") into bytes
func parseHumanSize(sizeStr string) (int64, error) {
parts := strings.Fields(sizeStr)
if len(parts) != 2 {
return 0, fmt.Errorf("invalid size format: %s", sizeStr)
}
value, err := strconv.ParseFloat(parts[0], 64)
if err != nil {
return 0, fmt.Errorf("invalid size value: %w", err)
}
unit := strings.ToUpper(parts[1])
switch unit {
case "B":
return int64(value), nil
case "KB", "KIB":
return int64(value * 1024), nil
case "MB", "MIB":
return int64(value * 1024 * 1024), nil
case "GB", "GIB":
return int64(value * 1024 * 1024 * 1024), nil
case "TB", "TIB":
return int64(value * 1024 * 1024 * 1024 * 1024), nil
default:
return 0, fmt.Errorf("unknown size unit: %s", unit)
}
}
// RestoreBackup restores a backup with the given ID
func (s *KopiaStrategy) RestoreBackup(ctx context.Context, backupID string, serviceName string) error {
func (s *Strategy) RestoreBackup(ctx context.Context, backupID string, serviceName string) error {
// Ensure repository is connected
err := s.EnsureRepositoryConnected(ctx, serviceName)
if err != nil {
return fmt.Errorf("failed to connect to repository: %w", err)
}
// Parse service group and index from service name
groupName, _, err := parseServiceName(serviceName)
if err != nil {
return fmt.Errorf("invalid service name format: %w", err)
}
// Get service directory from config
factory, err := NewBackupFactory("config.yml")
if err != nil {
return fmt.Errorf("failed to create factory: %w", err)
}
// Find service group
serviceGroup, exists := factory.Config.Services[groupName]
if !exists {
return fmt.Errorf("service group not found: %s", groupName)
// Get source directory from strategy instead of importing factory
targetDir := s.SourcePath
if targetDir == "" {
return fmt.Errorf("source path not specified")
}
// Create a temporary directory for restore
@ -489,7 +312,6 @@ func (s *KopiaStrategy) RestoreBackup(ctx context.Context, backupID string, serv
}
// Now we need to sync the restored data to the original directory
targetDir := serviceGroup.Source.Path
log.Printf("Syncing restored data from %s to %s", restoreDir, targetDir)
// Use rsync for the final transfer to avoid permissions issues
@ -519,8 +341,7 @@ func (s *KopiaStrategy) RestoreBackup(ctx context.Context, backupID string, serv
}
// DownloadBackup provides a reader with backup summary information in text format
func (s *KopiaStrategy) DownloadBackup(ctx context.Context, backupID string, serviceName string) (io.ReadCloser, error) {
func (s *Strategy) DownloadBackup(ctx context.Context, backupID string, serviceName string) (io.ReadCloser, error) {
// Ensure repository is connected
err := s.EnsureRepositoryConnected(ctx, serviceName)
if err != nil {
@ -647,8 +468,7 @@ func (s *KopiaStrategy) DownloadBackup(ctx context.Context, backupID string, ser
}
// GetBackupInfo returns detailed information about a specific backup
func (s *KopiaStrategy) GetBackupInfo(ctx context.Context, backupID string, serviceName string) (*BackupInfo, error) {
func (s *Strategy) GetBackupInfo(ctx context.Context, backupID string, serviceName string) (*models.BackupInfo, error) {
err := s.EnsureRepositoryConnected(ctx, serviceName)
if err != nil {
return nil, fmt.Errorf("failed to connect to repository: %w", err)
@ -717,7 +537,7 @@ func (s *KopiaStrategy) GetBackupInfo(ctx context.Context, backupID string, serv
}
}
return &BackupInfo{
return &models.BackupInfo{
ID: id,
CreationTime: creationTime,
Size: size,
@ -727,6 +547,65 @@ func (s *KopiaStrategy) GetBackupInfo(ctx context.Context, backupID string, serv
}, nil
}
// Helper method to ensure the repository is connected before operations
func (s *Strategy) EnsureRepositoryConnected(ctx context.Context, serviceName string) error {
// Check if kopia repository is already connected with config file
cmd := exec.Command("kopia", "--config-file", s.ConfigPath, "repository", "status")
err := cmd.Run()
if err != nil {
// Repository not connected, try to connect
password, err := security.GetOrCreatePassword(serviceName, 48)
if err != nil {
return fmt.Errorf("failed to get password: %w", err)
}
// Connect using provider
if err := s.Provider.Connect(ctx, serviceName, password, s.ConfigPath); err != nil {
return fmt.Errorf("failed to connect to repository: %w", err)
}
}
return nil
}
// parseServiceName splits a service name in the format "group.index" into its components
func parseServiceName(serviceName string) (string, string, error) {
parts := strings.SplitN(serviceName, ".", 2)
if len(parts) != 2 {
return "", "", fmt.Errorf("service name must be in format 'group.index', got '%s'", serviceName)
}
return parts[0], parts[1], nil
}
// parseHumanSize parses a human-readable size string (e.g., "32.8 MB") into bytes
func parseHumanSize(sizeStr string) (int64, error) {
parts := strings.Fields(sizeStr)
if len(parts) != 2 {
return 0, fmt.Errorf("invalid size format: %s", sizeStr)
}
value, err := strconv.ParseFloat(parts[0], 64)
if err != nil {
return 0, fmt.Errorf("invalid size value: %w", err)
}
unit := strings.ToUpper(parts[1])
switch unit {
case "B":
return int64(value), nil
case "KB", "KIB":
return int64(value * 1024), nil
case "MB", "MIB":
return int64(value * 1024 * 1024), nil
case "GB", "GIB":
return int64(value * 1024 * 1024 * 1024), nil
case "TB", "TIB":
return int64(value * 1024 * 1024 * 1024 * 1024), nil
default:
return 0, fmt.Errorf("unknown size unit: %s", unit)
}
}
// cleanupReadCloser is a wrapper around io.ReadCloser that performs cleanup when closed
type cleanupReadCloser struct {
io.ReadCloser
@ -739,29 +618,3 @@ func (c *cleanupReadCloser) Close() error {
c.cleanup()
return err
}
// extractServiceNameFromBackupID extracts the service name from a backup ID
// This is an approximation as the exact format depends on your ID structure
func extractServiceNameFromBackupID(backupID string) string {
// Kopia snapshot IDs don't directly include the service name
// You may need to adjust this based on your actual ID format
// Try to extract the pattern from your backups list
// If you're using ListBackups to find all backups, you might have
// a mapping of IDs to service names that you can use
// For this example, let's assume we're using environment variables to track this
envVar := fmt.Sprintf("BACKEA_SNAPSHOT_%s", backupID)
if serviceName := os.Getenv(envVar); serviceName != "" {
return serviceName
}
// Fallback: For testing, we'll return a default or parse from the first part
// This should be replaced with your actual logic
parts := strings.Split(backupID, "-")
if len(parts) > 0 {
return parts[0]
}
return "unknown"
}

View File

@ -0,0 +1,85 @@
package strategy
import (
"backea/internal/backup/models"
"context"
"io"
"time"
)
// BackupInfo contains information about a single backup
type BackupInfo struct {
ID string // Unique identifier for the backup
CreationTime time.Time // When the backup was created
Size int64 // Size in bytes
Source string // Original source path
Type string // Type of backup (e.g., "kopia", "restic")
RetentionTag string // Retention policy applied (e.g., "latest", "daily", "weekly")
}
// StorageUsageInfo contains information about backup storage usage
type StorageUsageInfo struct {
TotalBytes int64 // Total bytes stored
Provider string // Storage provider (e.g., "local", "b2", "s3")
ProviderID string // Provider-specific ID (bucket name, path, etc.)
}
// Strategy defines the backup/restore strategy interface
type Strategy interface {
// Execute performs a backup
Execute(ctx context.Context, serviceName, directory string) error
// ListBackups gets information about existing backups
ListBackups(ctx context.Context, serviceName string) ([]models.BackupInfo, error)
// GetStorageUsage returns information about the total storage used
GetStorageUsage(ctx context.Context, serviceName string) (*models.StorageUsageInfo, error)
// RestoreBackup restores a backup to its original location
RestoreBackup(ctx context.Context, backupID string, serviceName string) error
// DownloadBackup provides a reader with backup data for download
DownloadBackup(ctx context.Context, backupID, serviceName string) (io.ReadCloser, error)
// GetBackupInfo returns detailed information about a specific backup
GetBackupInfo(ctx context.Context, backupID string, serviceName string) (*models.BackupInfo, error)
}
// ServiceConfig represents the configuration for a service's backup
type BackupConfig struct {
BackupStrategy StrategyConfig // Strategy to use for backups
}
// StrategyConfig defines configuration for a backup strategy
type StrategyConfig struct {
Type string // Type of backup strategy (e.g., "kopia", "rsync")
Provider string // Storage provider (e.g., "local", "b2", "sftp")
Destination DestinationConfig // Destination configuration
Retention RetentionConfig // Retention policy
}
// DestinationConfig defines backup destination settings
type DestinationConfig struct {
Host string // Host for remote destination
Path string // Path for storage
Username string // Username for authentication
SSHKey string // SSH key for authentication
}
// RetentionConfig defines backup retention policy
type RetentionConfig struct {
KeepLatest int // Number of latest snapshots to keep
KeepHourly int // Number of hourly snapshots to keep
KeepDaily int // Number of daily snapshots to keep
KeepWeekly int // Number of weekly snapshots to keep
KeepMonthly int // Number of monthly snapshots to keep
KeepYearly int // Number of yearly snapshots to keep
}
// Factory creates backup strategies
// type Factory interface {
// CreateBackupStrategyForService(groupName, serviceIndex string) (Strategy, error)
// GetServicePath(groupName string) (string, error)
// GetServiceConfig(groupName, serviceIndex string) (*BackupConfig, error)
// ReloadConfig() error
// }

View File

@ -1,25 +1,21 @@
package server
import (
"backea/internal/backup"
"backea/internal/backup/strategy"
"backea/internal/web/routes"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
// New creates a new configured Echo server
func New(factory *backup.BackupFactory) *echo.Echo {
func New(factory strategy.Factory) *echo.Echo {
e := echo.New()
// Add middleware
e.Use(middleware.Logger())
e.Use(middleware.Recover())
// Setup static file serving
e.Static("/static", "static")
// Register routes
routes.RegisterRoutes(e, factory)
return e

View File

@ -1,7 +1,7 @@
package handlers
import (
"backea/internal/backup"
"backea/internal/backup/strategy"
"context"
"fmt"
"io"
@ -18,11 +18,11 @@ import (
// BackupActionsHandler handles backup-related actions like restore and download
type BackupActionsHandler struct {
backupFactory *backup.BackupFactory
backupFactory strategy.Factory
}
// NewBackupActionsHandler creates a new backup actions handler
func NewBackupActionsHandler(factory *backup.BackupFactory) *BackupActionsHandler {
func NewBackupActionsHandler(factory strategy.Factory) *BackupActionsHandler {
return &BackupActionsHandler{
backupFactory: factory,
}
@ -185,7 +185,7 @@ func (h *BackupActionsHandler) RestoreBackupForm(c echo.Context) error {
}
// restoreBackupToCustomDestination handles restoring a backup to a custom directory
func (h *BackupActionsHandler) restoreBackupToCustomDestination(ctx context.Context, strategy backup.Strategy, backupID, serviceName, destinationPath string) error {
func (h *BackupActionsHandler) restoreBackupToCustomDestination(ctx context.Context, strategy strategy.Strategy, backupID, serviceName, destinationPath string) error {
// Validate destination path
destinationPath = filepath.Clean(destinationPath)

View File

@ -1,7 +1,8 @@
package handlers
import (
"backea/internal/backup"
"backea/internal/backup/config"
"backea/internal/backup/strategy"
"backea/templates"
"context"
"fmt"
@ -16,10 +17,10 @@ import (
)
type HomepageHandler struct {
backupFactory *backup.BackupFactory
backupFactory strategy.Factory
}
func NewHomepageHandler(factory *backup.BackupFactory) *HomepageHandler {
func NewHomepageHandler(factory strategy.Factory) *HomepageHandler {
return &HomepageHandler{
backupFactory: factory,
}
@ -28,14 +29,20 @@ func NewHomepageHandler(factory *backup.BackupFactory) *HomepageHandler {
// Home handles the homepage request and displays latest backups by service
func (h *HomepageHandler) Home(c echo.Context) error {
// Create the data structures
serviceBackups := make(map[string]map[string][]backup.BackupInfo)
serviceBackups := make(map[string]map[string][]strategy.BackupInfo)
serviceConfigs := make(map[string]map[string]templates.ServiceProviderInfo)
groupDirectories := make(map[string]string) // Store directories by group name
// Get the configuration from the factory
factoryConfig, err := h.getConfig()
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to get configuration: %v", err))
}
// Process each service group
for groupName, serviceGroup := range h.backupFactory.Config.Services {
for groupName, serviceGroup := range factoryConfig.Services {
// Initialize maps for this group
serviceBackups[groupName] = make(map[string][]backup.BackupInfo)
serviceBackups[groupName] = make(map[string][]strategy.BackupInfo)
serviceConfigs[groupName] = make(map[string]templates.ServiceProviderInfo)
// Store the directory at the group level in a separate map
@ -60,7 +67,6 @@ func (h *HomepageHandler) Home(c echo.Context) error {
sort.Strings(sortedGroupNames)
// Render the template with sorted group names and directories
// We don't load actual backup data on initial page load, it will be loaded via HTMX
component := templates.Home(serviceBackups, serviceConfigs, sortedGroupNames, groupDirectories)
return component.Render(c.Request().Context(), c.Response().Writer)
}
@ -69,14 +75,20 @@ func (h *HomepageHandler) Home(c echo.Context) error {
func (h *HomepageHandler) ServiceGroupHeader(c echo.Context) error {
groupName := c.Param("groupName")
// Get the configuration
factoryConfig, err := h.getConfig()
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to get configuration: %v", err))
}
// Check if the service group exists
serviceGroup, exists := h.backupFactory.Config.Services[groupName]
serviceGroup, exists := factoryConfig.Services[groupName]
if !exists {
return echo.NewHTTPError(404, "Service group not found")
return echo.NewHTTPError(http.StatusNotFound, "Service group not found")
}
// Create data structures
serviceBackups := make(map[string][]backup.BackupInfo)
serviceBackups := make(map[string][]strategy.BackupInfo)
serviceConfigs := make(map[string]templates.ServiceProviderInfo)
// Setup synchronization
@ -140,14 +152,20 @@ func (h *HomepageHandler) ServiceGroupHeader(c echo.Context) error {
func (h *HomepageHandler) ServiceGroupBackups(c echo.Context) error {
groupName := c.Param("groupName")
// Get the configuration
factoryConfig, err := h.getConfig()
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to get configuration: %v", err))
}
// Check if the service group exists
serviceGroup, exists := h.backupFactory.Config.Services[groupName]
serviceGroup, exists := factoryConfig.Services[groupName]
if !exists {
return echo.NewHTTPError(404, "Service group not found")
return echo.NewHTTPError(http.StatusNotFound, "Service group not found")
}
// Create data structures
serviceBackups := make(map[string][]backup.BackupInfo)
serviceBackups := make(map[string][]strategy.BackupInfo)
serviceConfigs := make(map[string]templates.ServiceProviderInfo)
// Setup synchronization
@ -198,7 +216,7 @@ func (h *HomepageHandler) ServiceGroupBackups(c echo.Context) error {
wg.Wait()
// Create a map with just the group for the template
groupServiceBackups := make(map[string]map[string][]backup.BackupInfo)
groupServiceBackups := make(map[string]map[string][]strategy.BackupInfo)
groupServiceBackups[groupName] = serviceBackups
// Create a map with just the group configs for the template
@ -214,14 +232,20 @@ func (h *HomepageHandler) ServiceGroupBackups(c echo.Context) error {
func (h *HomepageHandler) ServiceGroupAllBackups(c echo.Context) error {
groupName := c.Param("groupName")
// Get the configuration
factoryConfig, err := h.getConfig()
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to get configuration: %v", err))
}
// Check if the service group exists
serviceGroup, exists := h.backupFactory.Config.Services[groupName]
serviceGroup, exists := factoryConfig.Services[groupName]
if !exists {
return echo.NewHTTPError(404, "Service group not found")
return echo.NewHTTPError(http.StatusNotFound, "Service group not found")
}
// Create data structures
serviceBackups := make(map[string][]backup.BackupInfo)
serviceBackups := make(map[string][]strategy.BackupInfo)
serviceConfigs := make(map[string]templates.ServiceProviderInfo)
// Setup synchronization
@ -272,7 +296,7 @@ func (h *HomepageHandler) ServiceGroupAllBackups(c echo.Context) error {
wg.Wait()
// Create a map with just the group for the template
groupServiceBackups := make(map[string]map[string][]backup.BackupInfo)
groupServiceBackups := make(map[string]map[string][]strategy.BackupInfo)
groupServiceBackups[groupName] = serviceBackups
// Create a map with just the group configs for the template
@ -315,3 +339,15 @@ func (h *HomepageHandler) ServiceGroupAllBackups(c echo.Context) error {
component := templates.ServiceGroupAllBackupsTable(groupName, serviceBackups, groupServiceConfigs)
return component.Render(c.Request().Context(), c.Response().Writer)
}
// Helper method to get configuration from factory
func (h *HomepageHandler) getConfig() (*config.Configuration, error) {
// Type assert to get the concrete FactoryImpl type which has Config field
if factoryImpl, ok := h.backupFactory.(*strategy.FactoryImpl); ok {
return factoryImpl.Config, nil
}
// If we can't access the Config directly, get it through factory methods
// This is where we'd need to add methods to the Factory interface if not already there
return nil, fmt.Errorf("cannot access configuration from factory")
}

View File

@ -1,7 +1,7 @@
package routes
import (
"backea/internal/backup"
"backea/internal/backup/strategy"
"backea/internal/web/handlers"
"fmt"
@ -9,7 +9,7 @@ import (
)
// RegisterRoutes sets up all the routes for the web application
func RegisterRoutes(e *echo.Echo, factory *backup.BackupFactory) {
func RegisterRoutes(e *echo.Echo, factory strategy.Factory) {
// Create handlers with backup factory
homeHandler := handlers.NewHomepageHandler(factory)
actionsHandler := handlers.NewBackupActionsHandler(factory)

View File

@ -5,15 +5,16 @@ package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"backea/internal/backup"
"backea/internal/backup/strategy"
"backea/templates/layouts"
"fmt"
"sort"
"time"
"github.com/a-h/templ"
templruntime "github.com/a-h/templ/runtime"
)
// FormatSize formats byte size to human-readable format
@ -156,7 +157,7 @@ func GetSortedBackups(serviceGroup map[string][]backup.BackupInfo) []BackupWithS
}
// Home renders the homepage with lazy-loaded backup information
func Home(serviceBackups map[string]map[string][]backup.BackupInfo, serviceConfigs map[string]map[string]ServiceProviderInfo, sortedGroupNames []string, groupDirectories map[string]string) templ.Component {
func Home(serviceBackups map[string]map[string][]strategy.BackupInfo, serviceConfigs map[string]map[string]ServiceProviderInfo, sortedGroupNames []string, groupDirectories map[string]string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {