293 lines
8.0 KiB
Go
293 lines
8.0 KiB
Go
package backup
|
|
|
|
import (
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// RestorableApp describes an app that can be restored during DR.
|
|
type RestorableApp struct {
|
|
Name string `json:"name"`
|
|
DisplayName string `json:"display_name"`
|
|
NeedsHDD bool `json:"needs_hdd"`
|
|
HDDPath string `json:"hdd_path,omitempty"`
|
|
|
|
// What was found on disk
|
|
HasConfig bool `json:"has_config"` // _config/ dir with compose files
|
|
ConfigPath string `json:"config_path"` // full path to _config/ backup
|
|
HasData bool `json:"has_data"` // app data dir exists on HDD
|
|
DataPath string `json:"data_path"` // e.g., /mnt/hdd_1/appdata/immich
|
|
HasDBDump bool `json:"has_db_dump"` // _db/ dir with dump files
|
|
DBDumpPath string `json:"db_dump_path"` // full path to _db/ backup
|
|
HasRsyncData bool `json:"has_rsync_data"` // rsync user data (excl _config/_db)
|
|
RsyncDataPath string `json:"rsync_data_path"` // full path to rsync backup
|
|
DrivePath string `json:"drive_path"` // which drive has the backup
|
|
DriveLabel string `json:"drive_label"` // label for display
|
|
|
|
// Restore progress (updated during restore)
|
|
Status string `json:"status"` // "pending", "restoring", "done", "failed", "skipped"
|
|
Error string `json:"error,omitempty"`
|
|
StartedAt string `json:"started_at,omitempty"`
|
|
CompletedAt string `json:"completed_at,omitempty"`
|
|
}
|
|
|
|
// RestorePlan holds the complete DR restore plan.
|
|
type RestorePlan struct {
|
|
mu sync.RWMutex
|
|
|
|
CustomerID string `json:"customer_id"`
|
|
Domain string `json:"domain"`
|
|
Timestamp string `json:"timestamp"` // when the infra backup was taken
|
|
Apps []RestorableApp `json:"apps"`
|
|
|
|
// Drive summary
|
|
Drives []DriveInfo `json:"drives"`
|
|
|
|
// Overall status
|
|
Status string `json:"status"` // "pending", "restoring", "done"
|
|
}
|
|
|
|
// DriveInfo summarizes a mounted drive for display.
|
|
type DriveInfo struct {
|
|
Path string `json:"path"`
|
|
Label string `json:"label"`
|
|
Available bool `json:"available"` // mount is accessible
|
|
HasBackup bool `json:"has_backup"` // has backups/secondary/ dir
|
|
}
|
|
|
|
// GetApps returns a snapshot of the apps list.
|
|
func (rp *RestorePlan) GetApps() []RestorableApp {
|
|
rp.mu.RLock()
|
|
defer rp.mu.RUnlock()
|
|
apps := make([]RestorableApp, len(rp.Apps))
|
|
copy(apps, rp.Apps)
|
|
return apps
|
|
}
|
|
|
|
// Snapshot returns a thread-safe snapshot of the plan for JSON serialization.
|
|
func (rp *RestorePlan) Snapshot() map[string]interface{} {
|
|
rp.mu.RLock()
|
|
defer rp.mu.RUnlock()
|
|
|
|
apps := make([]RestorableApp, len(rp.Apps))
|
|
copy(apps, rp.Apps)
|
|
drives := make([]DriveInfo, len(rp.Drives))
|
|
copy(drives, rp.Drives)
|
|
|
|
return map[string]interface{}{
|
|
"ok": true,
|
|
"status": rp.Status,
|
|
"apps": apps,
|
|
"drives": drives,
|
|
}
|
|
}
|
|
|
|
// TryStartRestore atomically sets status to "restoring" if not already restoring.
|
|
// Returns false if a restore is already in progress (prevents double-restore race).
|
|
func (rp *RestorePlan) TryStartRestore() bool {
|
|
rp.mu.Lock()
|
|
defer rp.mu.Unlock()
|
|
if rp.Status == "restoring" {
|
|
return false
|
|
}
|
|
rp.Status = "restoring"
|
|
return true
|
|
}
|
|
|
|
// SetStatus sets the overall plan status under lock.
|
|
func (rp *RestorePlan) SetStatus(status string) {
|
|
rp.mu.Lock()
|
|
defer rp.mu.Unlock()
|
|
rp.Status = status
|
|
}
|
|
|
|
// GetStatus returns the current plan status under lock.
|
|
func (rp *RestorePlan) GetStatus() string {
|
|
rp.mu.RLock()
|
|
defer rp.mu.RUnlock()
|
|
return rp.Status
|
|
}
|
|
|
|
// UpdateApp updates a single app's status in the plan.
|
|
func (rp *RestorePlan) UpdateApp(name, status, errMsg string) {
|
|
rp.mu.Lock()
|
|
defer rp.mu.Unlock()
|
|
for i := range rp.Apps {
|
|
if rp.Apps[i].Name == name {
|
|
rp.Apps[i].Status = status
|
|
rp.Apps[i].Error = errMsg
|
|
if status == "restoring" {
|
|
rp.Apps[i].StartedAt = time.Now().UTC().Format(time.RFC3339)
|
|
}
|
|
if status == "done" || status == "failed" {
|
|
rp.Apps[i].CompletedAt = time.Now().UTC().Format(time.RFC3339)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// AllDone returns true if all apps are done/failed/skipped.
|
|
func (rp *RestorePlan) AllDone() bool {
|
|
rp.mu.RLock()
|
|
defer rp.mu.RUnlock()
|
|
for _, app := range rp.Apps {
|
|
if app.Status != "done" && app.Status != "failed" && app.Status != "skipped" {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// InfraStackInfo is a minimal stack descriptor from the Hub infra backup.
|
|
// Used to pass deployed_stacks info into the scan without importing report.
|
|
type InfraStackInfo struct {
|
|
Name string
|
|
DisplayName string
|
|
HDDPath string
|
|
NeedsHDD bool
|
|
}
|
|
|
|
// ScanDrivesForBackups scans mounted drives for cross-drive backup data
|
|
// and correlates with the deployed stacks manifest from the Hub.
|
|
func ScanDrivesForBackups(mountedPaths []string, stacks []InfraStackInfo, logger *log.Logger) *RestorePlan {
|
|
plan := &RestorePlan{
|
|
Status: "pending",
|
|
}
|
|
|
|
// Build drive info and find backup directories
|
|
type driveBackup struct {
|
|
drivePath string
|
|
label string
|
|
secPath string // backups/secondary/ path
|
|
}
|
|
var backupDrives []driveBackup
|
|
|
|
for _, mp := range mountedPaths {
|
|
label := filepath.Base(mp)
|
|
avail := dirExists(mp)
|
|
|
|
di := DriveInfo{
|
|
Path: mp,
|
|
Label: label,
|
|
Available: avail,
|
|
}
|
|
|
|
secPath := SecondaryBackupPath(mp)
|
|
if dirExists(secPath) {
|
|
di.HasBackup = true
|
|
backupDrives = append(backupDrives, driveBackup{
|
|
drivePath: mp,
|
|
label: label,
|
|
secPath: secPath,
|
|
})
|
|
logger.Printf("[INFO] Found backup data on %s (%s)", mp, secPath)
|
|
}
|
|
|
|
plan.Drives = append(plan.Drives, di)
|
|
}
|
|
|
|
// For each stack from the manifest, look for backup data on drives
|
|
for _, stack := range stacks {
|
|
app := RestorableApp{
|
|
Name: stack.Name,
|
|
DisplayName: stack.DisplayName,
|
|
NeedsHDD: stack.NeedsHDD,
|
|
HDDPath: stack.HDDPath,
|
|
Status: "pending",
|
|
}
|
|
|
|
// Check if app data exists directly on HDD (common case: HDD survived)
|
|
if stack.HDDPath != "" {
|
|
dataDir := AppDataDir(stack.HDDPath, stack.Name)
|
|
if dirExists(dataDir) {
|
|
app.HasData = true
|
|
app.DataPath = dataDir
|
|
}
|
|
}
|
|
|
|
// Scan each drive for cross-drive backup of this app
|
|
for _, db := range backupDrives {
|
|
rsyncBase := AppSecondaryRsyncPath(db.drivePath, stack.Name)
|
|
if !dirExists(rsyncBase) {
|
|
continue
|
|
}
|
|
|
|
// Found a backup for this app
|
|
app.DrivePath = db.drivePath
|
|
app.DriveLabel = db.label
|
|
|
|
// Check for _config/ (stack compose directory backup)
|
|
configDir := filepath.Join(rsyncBase, "_config")
|
|
if dirExists(configDir) {
|
|
app.HasConfig = true
|
|
app.ConfigPath = configDir
|
|
}
|
|
|
|
// Check for _db/ (database dump backup)
|
|
dbDir := filepath.Join(rsyncBase, "_db")
|
|
if dirExists(dbDir) && !dirIsEmpty(dbDir) {
|
|
app.HasDBDump = true
|
|
app.DBDumpPath = dbDir
|
|
}
|
|
|
|
// Check for user data in rsync (anything besides _config and _db)
|
|
if hasUserData(rsyncBase) {
|
|
app.HasRsyncData = true
|
|
app.RsyncDataPath = rsyncBase
|
|
}
|
|
|
|
break // use first drive with backup for this app
|
|
}
|
|
|
|
plan.Apps = append(plan.Apps, app)
|
|
}
|
|
|
|
if len(plan.Apps) == 0 {
|
|
plan.Apps = []RestorableApp{}
|
|
}
|
|
|
|
logger.Printf("[INFO] Restore plan: %d apps, %d drives (%d with backups)",
|
|
len(plan.Apps), len(plan.Drives), len(backupDrives))
|
|
|
|
return plan
|
|
}
|
|
|
|
// dirExists checks if a directory exists and is accessible.
|
|
func dirExists(path string) bool {
|
|
info, err := os.Stat(path)
|
|
return err == nil && info.IsDir()
|
|
}
|
|
|
|
// dirIsEmpty returns true if a directory has no entries.
|
|
// Returns false on read errors (assume non-empty — safer for backup detection).
|
|
func dirIsEmpty(path string) bool {
|
|
entries, err := os.ReadDir(path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return len(entries) == 0
|
|
}
|
|
|
|
// hasUserData checks if the rsync backup dir has user data (not just _config/_db).
|
|
func hasUserData(rsyncBase string) bool {
|
|
entries, err := os.ReadDir(rsyncBase)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if name != "_config" && name != "_db" && !hasPrefix(name, ".") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func hasPrefix(s, prefix string) bool {
|
|
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
|
|
}
|