fix(backup): 4 bug fixes from v0.14.1 code review (v0.14.2)
Bug 1 (HIGH): add --exclude _* to rsync --delete so _db/ and _config/ directories are never deleted between backup runs (crossdrive.go) Bug 2 (MEDIUM): refactor RunDBDumps/RunBackup/RunFullBackup to use acquireRunning/releaseRunning helpers; extract runDBDumpsInternal and runBackupInternal so all three public entry points set m.running and RunFullBackup no longer deadlocks calling the public methods (backup.go) Bug 3 (MEDIUM): log [WARN] when GetDiskUsage returns nil in ValidateDestination instead of silently skipping space checks (crossdrive.go) Bug 4 (MEDIUM): add [WARN] on empty SystemDataPath in NewManager; add [ERROR] in GetAppDrivePath; guard DumpStackDB against empty/relative paths (backup.go) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -132,6 +132,9 @@ type BackupStatus struct {
|
|||||||
|
|
||||||
// NewManager creates a new backup manager.
|
// NewManager creates a new backup manager.
|
||||||
func NewManager(cfg *config.Config, pinger *monitor.Pinger, sett *settings.Settings, logger *log.Logger) *Manager {
|
func NewManager(cfg *config.Config, pinger *monitor.Pinger, sett *settings.Settings, logger *log.Logger) *Manager {
|
||||||
|
if cfg.Paths.SystemDataPath == "" {
|
||||||
|
logger.Printf("[WARN] SystemDataPath is empty in config — SSD-only apps will not have correct backup paths")
|
||||||
|
}
|
||||||
return &Manager{
|
return &Manager{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
restic: NewResticManager(cfg, logger),
|
restic: NewResticManager(cfg, logger),
|
||||||
@@ -150,6 +153,9 @@ func (m *Manager) GetAppDrivePath(stackName string) string {
|
|||||||
return hddPath
|
return hddPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if m.systemDataPath == "" {
|
||||||
|
m.logger.Printf("[ERROR] systemDataPath is empty — cannot determine drive for %s", stackName)
|
||||||
|
}
|
||||||
return m.systemDataPath
|
return m.systemDataPath
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +185,15 @@ func (m *Manager) activeDrives() []string {
|
|||||||
|
|
||||||
// RunDBDumps discovers and dumps all databases to per-drive, per-app paths.
|
// RunDBDumps discovers and dumps all databases to per-drive, per-app paths.
|
||||||
func (m *Manager) RunDBDumps(ctx context.Context) error {
|
func (m *Manager) RunDBDumps(ctx context.Context) error {
|
||||||
|
if err := m.acquireRunning(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer m.releaseRunning()
|
||||||
|
return m.runDBDumpsInternal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runDBDumpsInternal is the implementation of RunDBDumps. Caller must hold the running flag.
|
||||||
|
func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
m.logger.Printf("[INFO] Starting database dump run")
|
m.logger.Printf("[INFO] Starting database dump run")
|
||||||
|
|
||||||
@@ -270,6 +285,15 @@ func (m *Manager) RunDBDumps(ctx context.Context) error {
|
|||||||
|
|
||||||
// RunBackup runs per-drive restic backup snapshots.
|
// RunBackup runs per-drive restic backup snapshots.
|
||||||
func (m *Manager) RunBackup(ctx context.Context) error {
|
func (m *Manager) RunBackup(ctx context.Context) error {
|
||||||
|
if err := m.acquireRunning(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer m.releaseRunning()
|
||||||
|
return m.runBackupInternal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runBackupInternal is the implementation of RunBackup. Caller must hold the running flag.
|
||||||
|
func (m *Manager) runBackupInternal(ctx context.Context) error {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
m.logger.Printf("[INFO] Starting restic backup (per-drive)")
|
m.logger.Printf("[INFO] Starting restic backup (per-drive)")
|
||||||
|
|
||||||
@@ -452,27 +476,18 @@ func (m *Manager) RunIntegrityCheck(ctx context.Context) error {
|
|||||||
|
|
||||||
// RunFullBackup runs DB dumps followed by restic backup.
|
// RunFullBackup runs DB dumps followed by restic backup.
|
||||||
func (m *Manager) RunFullBackup(ctx context.Context) error {
|
func (m *Manager) RunFullBackup(ctx context.Context) error {
|
||||||
m.mu.Lock()
|
if err := m.acquireRunning(); err != nil {
|
||||||
if m.running {
|
return err
|
||||||
m.mu.Unlock()
|
|
||||||
return fmt.Errorf("backup already in progress")
|
|
||||||
}
|
}
|
||||||
m.running = true
|
defer m.releaseRunning()
|
||||||
m.mu.Unlock()
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
m.mu.Lock()
|
|
||||||
m.running = false
|
|
||||||
m.mu.Unlock()
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Step 1: DB dumps
|
// Step 1: DB dumps
|
||||||
if err := m.RunDBDumps(ctx); err != nil {
|
if err := m.runDBDumpsInternal(ctx); err != nil {
|
||||||
m.logger.Printf("[WARN] DB dump had errors, continuing with backup anyway")
|
m.logger.Printf("[WARN] DB dump had errors, continuing with backup anyway")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Restic backup
|
// Step 2: Restic backup
|
||||||
return m.RunBackup(ctx)
|
return m.runBackupInternal(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStatus returns the current backup status.
|
// GetStatus returns the current backup status.
|
||||||
@@ -498,6 +513,24 @@ func (m *Manager) IsRunning() bool {
|
|||||||
return m.running
|
return m.running
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// acquireRunning atomically sets the running flag. Returns error if already running.
|
||||||
|
func (m *Manager) acquireRunning() error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.running {
|
||||||
|
return fmt.Errorf("backup already in progress")
|
||||||
|
}
|
||||||
|
m.running = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseRunning clears the running flag.
|
||||||
|
func (m *Manager) releaseRunning() {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.running = false
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// GetResticPassword returns the restic repository encryption password.
|
// GetResticPassword returns the restic repository encryption password.
|
||||||
func (m *Manager) GetResticPassword() (string, error) {
|
func (m *Manager) GetResticPassword() (string, error) {
|
||||||
return m.restic.GetPassword()
|
return m.restic.GetPassword()
|
||||||
@@ -568,6 +601,9 @@ func (m *Manager) DumpStackDB(ctx context.Context, stackName string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
drivePath := m.GetAppDrivePath(stackName)
|
drivePath := m.GetAppDrivePath(stackName)
|
||||||
|
if drivePath == "" || !filepath.IsAbs(drivePath) {
|
||||||
|
return fmt.Errorf("cannot determine absolute drive path for %s (systemDataPath not configured?)", stackName)
|
||||||
|
}
|
||||||
dumpDir := AppDBDumpPath(drivePath, stackName)
|
dumpDir := AppDBDumpPath(drivePath, stackName)
|
||||||
|
|
||||||
m.logger.Printf("[INFO] Running pre-backup DB dump for %s (%d database(s)) → %s", stackName, len(stackDBs), dumpDir)
|
m.logger.Printf("[INFO] Running pre-backup DB dump for %s (%d database(s)) → %s", stackName, len(stackDBs), dumpDir)
|
||||||
|
|||||||
@@ -212,20 +212,23 @@ func (r *CrossDriveRunner) ValidateDestination(path string) error {
|
|||||||
if !system.IsWritable(path) {
|
if !system.IsWritable(path) {
|
||||||
return fmt.Errorf("destination %s is not writable", path)
|
return fmt.Errorf("destination %s is not writable", path)
|
||||||
}
|
}
|
||||||
if di := system.GetDiskUsage(path); di != nil {
|
di := system.GetDiskUsage(path)
|
||||||
if onSystemDrive {
|
if di == nil {
|
||||||
// System drive: protect OS stability — require ≥10 GB free and <90% used
|
r.logger.Printf("[WARN] Cannot determine disk usage for %s — proceeding without space verification", path)
|
||||||
if di.AvailGB < 10 {
|
return nil
|
||||||
return fmt.Errorf("destination %s is on the system drive with only %.1f GB free — at least 10 GB required to protect OS stability", path, di.AvailGB)
|
}
|
||||||
}
|
if onSystemDrive {
|
||||||
if di.UsedPercent >= 90 {
|
// System drive: protect OS stability — require ≥10 GB free and <90% used
|
||||||
return fmt.Errorf("destination %s is on the system drive at %.0f%% capacity — maximum 90%% allowed", path, di.UsedPercent)
|
if di.AvailGB < 10 {
|
||||||
}
|
return fmt.Errorf("destination %s is on the system drive with only %.1f GB free — at least 10 GB required to protect OS stability", path, di.AvailGB)
|
||||||
} else {
|
}
|
||||||
// External drive: just ensure it's not completely full
|
if di.UsedPercent >= 90 {
|
||||||
if di.AvailGB < 0.1 {
|
return fmt.Errorf("destination %s is on the system drive at %.0f%% capacity — maximum 90%% allowed", path, di.UsedPercent)
|
||||||
return fmt.Errorf("destination %s has insufficient free space (%.1f GB free)", path, di.AvailGB)
|
}
|
||||||
}
|
} else {
|
||||||
|
// External drive: just ensure it's not completely full
|
||||||
|
if di.AvailGB < 0.1 {
|
||||||
|
return fmt.Errorf("destination %s has insufficient free space (%.1f GB free)", path, di.AvailGB)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -263,8 +266,11 @@ func (r *CrossDriveRunner) runRsyncBackup(ctx context.Context, stackName, destBa
|
|||||||
src := strings.TrimRight(srcMount, "/") + "/"
|
src := strings.TrimRight(srcMount, "/") + "/"
|
||||||
dst := strings.TrimRight(dstPath, "/") + "/"
|
dst := strings.TrimRight(dstPath, "/") + "/"
|
||||||
|
|
||||||
|
// Exclude controller-managed directories (underscore prefix) to prevent --delete from removing
|
||||||
|
// _db/ and _config/ that were created by previous backup runs.
|
||||||
// Exclude app-internal DB dump files — the controller handles DB backups via pg_dump separately.
|
// Exclude app-internal DB dump files — the controller handles DB backups via pg_dump separately.
|
||||||
cmd := exec.CommandContext(ctx, "rsync", "-a", "--delete",
|
cmd := exec.CommandContext(ctx, "rsync", "-a", "--delete",
|
||||||
|
"--exclude", "_*",
|
||||||
"--exclude", "backups/*.sql.gz",
|
"--exclude", "backups/*.sql.gz",
|
||||||
"--exclude", "backups/*.sql",
|
"--exclude", "backups/*.sql",
|
||||||
"--exclude", "backups/*.dump",
|
"--exclude", "backups/*.dump",
|
||||||
|
|||||||
Reference in New Issue
Block a user