v0.11.8 — Per-App Cross-Drive Backup (3-2-1 rule)
New feature: backup app data to a secondary storage drive to satisfy
the "different media" requirement of the 3-2-1 backup rule.
- settings.go: CrossDriveBackup struct, AppBackupPrefs.CrossDrive field,
getter/setter methods, GetOrCreateCrossDrivePassword, preserves
cross-drive config when toggling nightly backup
- crossdrive.go (new): CrossDriveRunner with rsync and restic backends.
Validates destination (mount point, writable), prevents source/dest
overlap, per-app concurrency lock, persists last_run/status/size.
- main.go: wire CrossDriveRunner, register cross-drive-daily (03:30)
and cross-drive-weekly (04:30 Sundays) scheduler jobs
- router.go: 4 new API endpoints — save config, trigger run, get status,
run-all. Router now accepts Settings and CrossDriveRunner.
- server.go: Server struct accepts CrossDriveRunner, new web route
POST /settings/cross-backup/{name}
- handlers.go: deployHandler populates CrossDriveConfig, BackupDestPaths,
BackupDestWarning, AppBackupEnabled. settingsCrossBackupHandler saves
config. backupsHandler builds CrossDriveSummary, UnconfiguredApps,
CrossDriveWarnings for backup page.
- deploy.html: "Biztonsági mentés" card with destination/method/schedule
dropdowns, last-run status, manual trigger button, flash messages.
- backups.html: "Másolatok másik meghajtóra" section with per-app
status rows, unconfigured app warnings, "Összes futtatása most" button.
- style.css: margin-bottom fix for .deploy-stale-data, new cross-drive
card and list styles.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -11,6 +13,9 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// cryptoRandRead is a var so tests can stub it.
|
||||
var cryptoRandRead = func(b []byte) (int, error) { return io.ReadFull(rand.Reader, b) }
|
||||
|
||||
// Settings holds customer-modifiable overrides and cached state.
|
||||
// Persisted as a single JSON file (settings.json) in the data directory.
|
||||
type Settings struct {
|
||||
@@ -32,11 +37,33 @@ type Settings struct {
|
||||
|
||||
// Storage paths registry
|
||||
StoragePaths []StoragePath `json:"storage_paths,omitempty"`
|
||||
|
||||
// Cross-drive restic repo password (auto-generated on first use)
|
||||
CrossDriveResticPassword string `json:"cross_drive_restic_password,omitempty"`
|
||||
}
|
||||
|
||||
// AppBackupPrefs holds per-app backup toggle state.
|
||||
type AppBackupPrefs struct {
|
||||
// Existing: includes app data in nightly restic (same drive)
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// Cross-drive backup to secondary storage
|
||||
CrossDrive *CrossDriveBackup `json:"cross_drive,omitempty"`
|
||||
}
|
||||
|
||||
// CrossDriveBackup configures per-app backup to a secondary drive.
|
||||
type CrossDriveBackup struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Method string `json:"method"` // "rsync" or "restic"
|
||||
DestinationPath string `json:"destination_path"` // e.g., "/mnt/hdd_1"
|
||||
Schedule string `json:"schedule"` // "daily", "weekly", "manual"
|
||||
|
||||
// Runtime state (updated by backup runner, persisted for display)
|
||||
LastRun string `json:"last_run,omitempty"` // RFC3339
|
||||
LastStatus string `json:"last_status,omitempty"` // "ok", "error", "running"
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastDuration string `json:"last_duration,omitempty"` // "2m34s"
|
||||
LastSizeHuman string `json:"last_size_human,omitempty"` // "1.2 GB"
|
||||
}
|
||||
|
||||
// StoragePath represents a registered external storage location.
|
||||
@@ -204,13 +231,16 @@ func (s *Settings) IsAppBackupEnabled(stackName string) bool {
|
||||
}
|
||||
|
||||
// SetAppBackup enables or disables backup for a stack and saves to disk.
|
||||
// Preserves existing CrossDrive config.
|
||||
func (s *Settings) SetAppBackup(stackName string, enabled bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.AppBackup == nil {
|
||||
s.AppBackup = make(map[string]AppBackupPrefs)
|
||||
}
|
||||
s.AppBackup[stackName] = AppBackupPrefs{Enabled: enabled}
|
||||
existing := s.AppBackup[stackName]
|
||||
existing.Enabled = enabled
|
||||
s.AppBackup[stackName] = existing
|
||||
return s.save()
|
||||
}
|
||||
|
||||
@@ -229,16 +259,111 @@ func (s *Settings) GetAppBackupMap() map[string]bool {
|
||||
}
|
||||
|
||||
// SetAppBackupBulk updates backup prefs for all stacks at once and saves to disk.
|
||||
// Preserves existing CrossDrive configs.
|
||||
func (s *Settings) SetAppBackupBulk(prefs map[string]bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.AppBackup = make(map[string]AppBackupPrefs, len(prefs))
|
||||
newMap := make(map[string]AppBackupPrefs, len(prefs))
|
||||
for name, enabled := range prefs {
|
||||
s.AppBackup[name] = AppBackupPrefs{Enabled: enabled}
|
||||
existing := s.AppBackup[name] // preserves CrossDrive
|
||||
existing.Enabled = enabled
|
||||
newMap[name] = existing
|
||||
}
|
||||
s.AppBackup = newMap
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// GetAppBackupPrefs returns the full AppBackupPrefs for a stack.
|
||||
func (s *Settings) GetAppBackupPrefs(stackName string) (AppBackupPrefs, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if s.AppBackup == nil {
|
||||
return AppBackupPrefs{}, false
|
||||
}
|
||||
prefs, ok := s.AppBackup[stackName]
|
||||
return prefs, ok
|
||||
}
|
||||
|
||||
// GetCrossDriveConfig returns the cross-drive backup config for a stack (nil if not set).
|
||||
func (s *Settings) GetCrossDriveConfig(stackName string) *CrossDriveBackup {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if s.AppBackup == nil {
|
||||
return nil
|
||||
}
|
||||
prefs, ok := s.AppBackup[stackName]
|
||||
if !ok || prefs.CrossDrive == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *prefs.CrossDrive
|
||||
return &cp
|
||||
}
|
||||
|
||||
// SetCrossDriveConfig saves (or clears) the cross-drive backup config for a stack.
|
||||
func (s *Settings) SetCrossDriveConfig(stackName string, cfg *CrossDriveBackup) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.AppBackup == nil {
|
||||
s.AppBackup = make(map[string]AppBackupPrefs)
|
||||
}
|
||||
existing := s.AppBackup[stackName]
|
||||
existing.CrossDrive = cfg
|
||||
s.AppBackup[stackName] = existing
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// UpdateCrossDriveStatus updates runtime status fields for a cross-drive backup in-place.
|
||||
// fn receives a pointer to the CrossDriveBackup (creates one if nil) and may mutate it.
|
||||
func (s *Settings) UpdateCrossDriveStatus(stackName string, fn func(*CrossDriveBackup)) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.AppBackup == nil {
|
||||
s.AppBackup = make(map[string]AppBackupPrefs)
|
||||
}
|
||||
existing := s.AppBackup[stackName]
|
||||
if existing.CrossDrive == nil {
|
||||
return nil // don't create config from thin air — just skip status update
|
||||
}
|
||||
fn(existing.CrossDrive)
|
||||
s.AppBackup[stackName] = existing
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// GetAllCrossDriveConfigs returns all apps with a cross-drive config (enabled or not).
|
||||
func (s *Settings) GetAllCrossDriveConfigs() map[string]*CrossDriveBackup {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
result := make(map[string]*CrossDriveBackup)
|
||||
for name, prefs := range s.AppBackup {
|
||||
if prefs.CrossDrive != nil {
|
||||
cp := *prefs.CrossDrive
|
||||
result[name] = &cp
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetOrCreateCrossDrivePassword returns the cross-drive restic password,
|
||||
// generating and persisting one if it doesn't exist yet.
|
||||
func (s *Settings) GetOrCreateCrossDrivePassword() (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.CrossDriveResticPassword != "" {
|
||||
return s.CrossDriveResticPassword, nil
|
||||
}
|
||||
// Generate a random 32-byte password
|
||||
buf := make([]byte, 32)
|
||||
_, err := cryptoRandRead(buf)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generating cross-drive restic password: %w", err)
|
||||
}
|
||||
s.CrossDriveResticPassword = fmt.Sprintf("%x", buf)
|
||||
if err := s.save(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.CrossDriveResticPassword, nil
|
||||
}
|
||||
|
||||
// --- Storage Paths ---
|
||||
|
||||
// GetStoragePaths returns a copy of all registered storage paths.
|
||||
|
||||
Reference in New Issue
Block a user