v0.14.0: Per-drive backup architecture + storage path overhaul
Major refactor of backup and storage paths: - Per-drive restic repos at <drive>/backups/primary/restic/ - Per-app DB dumps at <drive>/backups/primary/<app>/db-dumps/ - Remove global BackupDir, DBDumpDir, ResticRepo config fields - Add SystemDataPath config (fallback for apps without HDD) - New backup/paths.go with pure path computation helpers - Add GetStackHDDPath to StackDataProvider interface - Restic methods now accept repoPath as parameter - Cross-drive backup uses new secondary path structure - Rename storage/ to appdata/ in scripts and compose templates - Update protected HDD paths (storage → appdata + backups) - Simplify backup UI (remove global path displays) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -17,8 +17,8 @@ import (
|
||||
)
|
||||
|
||||
// ResticManager handles restic backup operations.
|
||||
// All methods accept repoPath as parameter to support per-drive repos.
|
||||
type ResticManager struct {
|
||||
repoPath string
|
||||
passwordFile string
|
||||
logger *log.Logger
|
||||
customerID string
|
||||
@@ -36,15 +36,17 @@ type SnapshotResult struct {
|
||||
|
||||
// SnapshotInfo holds information about a restic snapshot.
|
||||
type SnapshotInfo struct {
|
||||
ID string `json:"short_id"`
|
||||
Time time.Time `json:"time"`
|
||||
Paths []string `json:"paths"`
|
||||
Tags []string `json:"tags"`
|
||||
ID string `json:"short_id"`
|
||||
Time time.Time `json:"time"`
|
||||
Paths []string `json:"paths"`
|
||||
Tags []string `json:"tags"`
|
||||
RepoPath string `json:"-"` // set by caller for multi-repo aggregation
|
||||
}
|
||||
|
||||
// RepoStats holds repository statistics.
|
||||
type RepoStats struct {
|
||||
TotalSize string
|
||||
TotalSizeBytes int64
|
||||
SnapshotCount int
|
||||
LatestSnapshot *SnapshotInfo
|
||||
}
|
||||
@@ -52,7 +54,6 @@ type RepoStats struct {
|
||||
// NewResticManager creates a new restic manager.
|
||||
func NewResticManager(cfg *config.Config, logger *log.Logger) *ResticManager {
|
||||
return &ResticManager{
|
||||
repoPath: cfg.Backup.ResticRepo,
|
||||
passwordFile: cfg.Backup.ResticPasswordFile,
|
||||
logger: logger,
|
||||
customerID: cfg.Customer.ID,
|
||||
@@ -62,7 +63,7 @@ func NewResticManager(cfg *config.Config, logger *log.Logger) *ResticManager {
|
||||
|
||||
// EnsureInitialized checks if the restic repo exists and initializes it if not.
|
||||
// Also auto-generates the password file if missing.
|
||||
func (r *ResticManager) EnsureInitialized() error {
|
||||
func (r *ResticManager) EnsureInitialized(repoPath string) error {
|
||||
// Ensure password file exists
|
||||
if _, err := os.Stat(r.passwordFile); os.IsNotExist(err) {
|
||||
if err := r.generatePassword(); err != nil {
|
||||
@@ -74,23 +75,23 @@ func (r *ResticManager) EnsureInitialized() error {
|
||||
os.MkdirAll(r.cacheDir, 0700)
|
||||
|
||||
// Check if repo is already initialized
|
||||
configPath := filepath.Join(r.repoPath, "config")
|
||||
configPath := filepath.Join(repoPath, "config")
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
r.logger.Printf("[INFO] Restic repo already initialized at %s", r.repoPath)
|
||||
r.logger.Printf("[INFO] Restic repo already initialized at %s", repoPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure repo directory exists
|
||||
if err := os.MkdirAll(r.repoPath, 0700); err != nil {
|
||||
if err := os.MkdirAll(repoPath, 0700); err != nil {
|
||||
return fmt.Errorf("creating repo dir: %w", err)
|
||||
}
|
||||
|
||||
// Initialize repo
|
||||
r.logger.Printf("[INFO] Initializing restic repository at %s", r.repoPath)
|
||||
r.logger.Printf("[INFO] Initializing restic repository at %s", repoPath)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
cmd := r.command(ctx, "init")
|
||||
cmd := r.command(ctx, repoPath, "init")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("restic init failed: %v — %s", err, truncate(string(out), 200))
|
||||
@@ -101,7 +102,7 @@ func (r *ResticManager) EnsureInitialized() error {
|
||||
}
|
||||
|
||||
// Snapshot creates a new backup snapshot of the given paths.
|
||||
func (r *ResticManager) Snapshot(paths []string, tags []string) (*SnapshotResult, error) {
|
||||
func (r *ResticManager) Snapshot(repoPath string, paths []string, tags []string) (*SnapshotResult, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
@@ -128,17 +129,17 @@ func (r *ResticManager) Snapshot(paths []string, tags []string) (*SnapshotResult
|
||||
}
|
||||
args = append(args, existingPaths...)
|
||||
|
||||
cmd := r.command(ctx, args...)
|
||||
cmd := r.command(ctx, repoPath, args...)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
// Check for stale lock
|
||||
errStr := string(out)
|
||||
if strings.Contains(errStr, "lock") || strings.Contains(errStr, "locked") {
|
||||
r.logger.Printf("[WARN] Restic repo locked — attempting unlock")
|
||||
unlockCmd := r.command(ctx, "unlock")
|
||||
unlockCmd := r.command(ctx, repoPath, "unlock")
|
||||
unlockCmd.Run()
|
||||
// Retry once
|
||||
cmd = r.command(ctx, args...)
|
||||
cmd = r.command(ctx, repoPath, args...)
|
||||
out, err = cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("restic backup failed after unlock: %v", err)
|
||||
@@ -181,7 +182,7 @@ func (r *ResticManager) Snapshot(paths []string, tags []string) (*SnapshotResult
|
||||
}
|
||||
|
||||
// Prune removes old snapshots according to retention policy.
|
||||
func (r *ResticManager) Prune(retention config.RetentionConfig) error {
|
||||
func (r *ResticManager) Prune(repoPath string, retention config.RetentionConfig) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
@@ -193,22 +194,22 @@ func (r *ResticManager) Prune(retention config.RetentionConfig) error {
|
||||
"--prune",
|
||||
}
|
||||
|
||||
cmd := r.command(ctx, args...)
|
||||
cmd := r.command(ctx, repoPath, args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("restic forget/prune failed: %v — %s", err, truncate(string(out), 200))
|
||||
}
|
||||
|
||||
r.logger.Printf("[INFO] Restic prune completed")
|
||||
r.logger.Printf("[INFO] Restic prune completed for %s", repoPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check verifies repository integrity.
|
||||
func (r *ResticManager) Check() error {
|
||||
func (r *ResticManager) Check(repoPath string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
cmd := r.command(ctx, "check")
|
||||
cmd := r.command(ctx, repoPath, "check")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("restic check failed: %v — %s", err, truncate(string(out), 200))
|
||||
@@ -217,11 +218,11 @@ func (r *ResticManager) Check() error {
|
||||
}
|
||||
|
||||
// ListSnapshots returns all snapshots, newest first, limited to N entries.
|
||||
func (r *ResticManager) ListSnapshots(limit int) ([]SnapshotInfo, error) {
|
||||
func (r *ResticManager) ListSnapshots(repoPath string, limit int) ([]SnapshotInfo, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
cmd := r.command(ctx, "snapshots", "--json")
|
||||
cmd := r.command(ctx, repoPath, "snapshots", "--json")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("restic snapshots failed: %v", err)
|
||||
@@ -245,11 +246,11 @@ func (r *ResticManager) ListSnapshots(limit int) ([]SnapshotInfo, error) {
|
||||
}
|
||||
|
||||
// LatestSnapshot returns the most recent snapshot info.
|
||||
func (r *ResticManager) LatestSnapshot() (*SnapshotInfo, error) {
|
||||
func (r *ResticManager) LatestSnapshot(repoPath string) (*SnapshotInfo, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
cmd := r.command(ctx, "snapshots", "--latest", "1", "--json")
|
||||
cmd := r.command(ctx, repoPath, "snapshots", "--latest", "1", "--json")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("restic snapshots failed: %v", err)
|
||||
@@ -268,26 +269,27 @@ func (r *ResticManager) LatestSnapshot() (*SnapshotInfo, error) {
|
||||
}
|
||||
|
||||
// Stats returns repository statistics.
|
||||
func (r *ResticManager) Stats() (*RepoStats, error) {
|
||||
func (r *ResticManager) Stats(repoPath string) (*RepoStats, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
stats := &RepoStats{}
|
||||
|
||||
// Get repo size
|
||||
cmd := r.command(ctx, "stats", "--json")
|
||||
cmd := r.command(ctx, repoPath, "stats", "--json")
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
var raw struct {
|
||||
TotalSize uint64 `json:"total_size"`
|
||||
}
|
||||
if json.Unmarshal(out, &raw) == nil {
|
||||
stats.TotalSize = humanizeBytes(int64(raw.TotalSize))
|
||||
stats.TotalSizeBytes = int64(raw.TotalSize)
|
||||
stats.TotalSize = humanizeBytes(stats.TotalSizeBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// Count snapshots
|
||||
cmd = r.command(ctx, "snapshots", "--json")
|
||||
cmd = r.command(ctx, repoPath, "snapshots", "--json")
|
||||
out, err = cmd.Output()
|
||||
if err == nil {
|
||||
var snapshots []SnapshotInfo
|
||||
@@ -313,7 +315,7 @@ func (r *ResticManager) GetPassword() (string, error) {
|
||||
}
|
||||
|
||||
// RestoreAppData restores specific paths from a restic snapshot.
|
||||
func (r *ResticManager) RestoreAppData(snapshotID string, paths []string) error {
|
||||
func (r *ResticManager) RestoreAppData(repoPath string, snapshotID string, paths []string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
@@ -325,9 +327,9 @@ func (r *ResticManager) RestoreAppData(snapshotID string, paths []string) error
|
||||
args = append(args, "--include", p)
|
||||
}
|
||||
|
||||
r.logger.Printf("[WARN] RESTORE started: snapshot=%s, paths=%v", snapshotID, paths)
|
||||
r.logger.Printf("[WARN] RESTORE started: repo=%s, snapshot=%s, paths=%v", repoPath, snapshotID, paths)
|
||||
|
||||
cmd := r.command(ctx, args...)
|
||||
cmd := r.command(ctx, repoPath, args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
r.logger.Printf("[ERROR] Restore failed: %v, output: %s", err, truncate(string(output), 500))
|
||||
@@ -338,10 +340,16 @@ func (r *ResticManager) RestoreAppData(snapshotID string, paths []string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ResticManager) command(ctx context.Context, args ...string) *exec.Cmd {
|
||||
// RepoExists checks if a restic repo is initialized at the given path.
|
||||
func (r *ResticManager) RepoExists(repoPath string) bool {
|
||||
_, err := os.Stat(filepath.Join(repoPath, "config"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (r *ResticManager) command(ctx context.Context, repoPath string, args ...string) *exec.Cmd {
|
||||
cmd := exec.CommandContext(ctx, "restic", args...)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"RESTIC_REPOSITORY="+r.repoPath,
|
||||
"RESTIC_REPOSITORY="+repoPath,
|
||||
"RESTIC_PASSWORD_FILE="+r.passwordFile,
|
||||
"RESTIC_CACHE_DIR="+r.cacheDir,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user