Phase 3 complete: per-app backup toggles, restore, storage overview

- Storage overview on backup page (SSD/HDD bars, repo stats)
- Restic password visibility + hub sync for disaster recovery
- App data discovery (HDD bind mounts, Docker volumes)
- Per-app backup toggle checkboxes with settings persistence
- Dynamic backup paths: enabled app HDD data included in restic snapshots
- Limited app restore from snapshots (self-service recovery)
- Snapshots API endpoint for restore dropdown
- Version bump to 0.8.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 21:29:56 +01:00
parent a3af7c6a2d
commit 7d801d1094
15 changed files with 1088 additions and 29 deletions
+181
View File
@@ -0,0 +1,181 @@
package backup
import (
"fmt"
"os"
"os/exec"
"strings"
"gopkg.in/yaml.v3"
)
// StackDataProvider provides stack data to the backup package without circular imports.
type StackDataProvider interface {
GetStackComposePath(name string) (composePath string, ok bool)
ListDeployedStacks() []StackSummary
GetStackHDDMounts(name string) []string
}
// StackSummary holds minimal stack info needed for app data discovery.
type StackSummary struct {
Name string
DisplayName string
ComposePath string
NeedsHDD bool
}
// AppBackupInfo holds backup-relevant data paths for a deployed app.
type AppBackupInfo struct {
StackName string
DisplayName string
NeedsHDD bool
HDDPaths []AppDataPath
HDDTotalSize int64
HDDSizeHuman string
DockerVolumes []AppDockerVolume
BackupEnabled bool
HasHDDData bool
HasDBDump bool
}
// AppDataPath represents a single HDD bind mount path.
type AppDataPath struct {
HostPath string
Exists bool
SizeHuman string
SizeBytes int64
}
// AppDockerVolume represents a named Docker volume.
type AppDockerVolume struct {
Name string
Contains string
}
// DiscoverAppData discovers backup-relevant data for all deployed apps.
func DiscoverAppData(provider StackDataProvider, hddPath string, backupPrefs map[string]bool, discoveredDBs []DiscoveredDB) []AppBackupInfo {
if provider == nil {
return nil
}
var result []AppBackupInfo
for _, stack := range provider.ListDeployedStacks() {
info := AppBackupInfo{
StackName: stack.Name,
DisplayName: stack.DisplayName,
NeedsHDD: stack.NeedsHDD,
}
// Discover HDD bind mounts via adapter
hddMounts := provider.GetStackHDDMounts(stack.Name)
for _, mount := range hddMounts {
path := AppDataPath{HostPath: mount}
if fi, err := os.Stat(mount); err == nil && fi.IsDir() {
path.Exists = true
path.SizeBytes = appDirSizeBytes(mount)
path.SizeHuman = appDirSizeHuman(mount)
}
info.HDDPaths = append(info.HDDPaths, path)
info.HDDTotalSize += path.SizeBytes
}
info.HDDSizeHuman = humanizeBytes(info.HDDTotalSize)
info.HasHDDData = len(info.HDDPaths) > 0
// Discover Docker named volumes from compose
info.DockerVolumes = parseComposeNamedVolumes(stack.ComposePath)
// Check if app has a DB container (already backed up via DB dump)
for _, db := range discoveredDBs {
if db.StackName == stack.Name {
info.HasDBDump = true
break
}
}
info.BackupEnabled = backupPrefs[stack.Name]
// Only include apps that have some data to show
if info.HasHDDData || len(info.DockerVolumes) > 0 {
result = append(result, info)
}
}
return result
}
// parseComposeNamedVolumes extracts named Docker volumes from a docker-compose.yml.
func parseComposeNamedVolumes(composePath string) []AppDockerVolume {
data, err := os.ReadFile(composePath)
if err != nil {
return nil
}
var compose struct {
Volumes map[string]interface{} `yaml:"volumes"`
}
if err := yaml.Unmarshal(data, &compose); err != nil {
return nil
}
var volumes []AppDockerVolume
for name, cfg := range compose.Volumes {
// Skip external volumes
if cfgMap, ok := cfg.(map[string]interface{}); ok {
if ext, ok := cfgMap["external"]; ok && ext == true {
continue
}
}
volumes = append(volumes, AppDockerVolume{Name: name})
}
return volumes
}
// appDirSizeHuman returns a human-readable size string for a directory using du.
func appDirSizeHuman(path string) string {
cmd := exec.Command("du", "-sh", path)
output, err := cmd.Output()
if err != nil {
return "?"
}
fields := strings.Fields(string(output))
if len(fields) > 0 {
return fields[0]
}
return "?"
}
// appDirSizeBytes returns the total size in bytes for a directory.
func appDirSizeBytes(path string) int64 {
cmd := exec.Command("du", "-sb", path)
output, err := cmd.Output()
if err != nil {
return 0
}
fields := strings.Fields(string(output))
if len(fields) > 0 {
var size int64
fmt.Sscanf(fields[0], "%d", &size)
return size
}
return 0
}
// humanizeBytes converts bytes to a human-readable string.
func humanizeBytes(b int64) string {
const (
KB = 1024
MB = KB * 1024
GB = MB * 1024
)
switch {
case b >= GB:
return fmt.Sprintf("%.1f GB", float64(b)/float64(GB))
case b >= MB:
return fmt.Sprintf("%.1f MB", float64(b)/float64(MB))
case b >= KB:
return fmt.Sprintf("%.1f KB", float64(b)/float64(KB))
default:
return fmt.Sprintf("%d B", b)
}
}
+80 -7
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
@@ -16,11 +17,12 @@ import (
// Manager orchestrates database dumps and restic backups.
type Manager struct {
cfg *config.Config
restic *ResticManager
logger *log.Logger
pinger *monitor.Pinger
settings *settings.Settings
cfg *config.Config
restic *ResticManager
logger *log.Logger
pinger *monitor.Pinger
settings *settings.Settings
stackProvider StackDataProvider
mu sync.Mutex
lastDBDump *DBDumpStatus
@@ -82,6 +84,13 @@ type FullBackupStatus struct {
// Remote (placeholder)
RemoteEnabled bool
// App data backup
AppDataInfo []AppBackupInfo
// Flash messages (set by handlers, passed through redirect)
FlashSuccess string
FlashError string
}
// DBDumpStatus holds the last DB dump result.
@@ -209,12 +218,17 @@ func (m *Manager) RunBackup(ctx context.Context) error {
return err
}
// Backup paths
// Backup paths: base + dynamic app data
paths := []string{
m.cfg.Paths.StacksDir,
m.cfg.Paths.DBDumpDir,
"/opt/docker/felhom-controller/controller.yaml",
}
appPaths := m.resolveAppBackupPaths()
if len(appPaths) > 0 {
paths = append(paths, appPaths...)
m.logger.Printf("[INFO] Backup paths (%d total, %d app data): %v", len(paths), len(appPaths), paths)
}
tags := []string{"felhom", m.cfg.Customer.ID}
result, err := m.restic.Snapshot(paths, tags)
@@ -358,13 +372,60 @@ func (m *Manager) GetRepoStats() (*RepoStats, error) {
return m.restic.Stats()
}
// IsRunning returns whether a backup is currently in progress.
// IsRunning returns whether a backup or restore is currently in progress.
func (m *Manager) IsRunning() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.running
}
// GetResticPassword returns the restic repository encryption password.
func (m *Manager) GetResticPassword() (string, error) {
return m.restic.GetPassword()
}
// ListSnapshots returns snapshots from the restic repository.
func (m *Manager) ListSnapshots(limit int) ([]SnapshotInfo, error) {
return m.restic.ListSnapshots(limit)
}
// SetStackProvider sets the stack data provider for app data discovery.
func (m *Manager) SetStackProvider(provider StackDataProvider) {
m.stackProvider = provider
}
// resolveAppBackupPaths returns HDD paths for all enabled app backups.
func (m *Manager) resolveAppBackupPaths() []string {
if m.stackProvider == nil || m.settings == nil {
return nil
}
appBackupMap := m.settings.GetAppBackupMap()
if len(appBackupMap) == 0 {
return nil
}
var paths []string
seen := make(map[string]bool)
for stackName, enabled := range appBackupMap {
if !enabled {
continue
}
hddMounts := m.stackProvider.GetStackHDDMounts(stackName)
for _, mount := range hddMounts {
if seen[mount] {
continue
}
if _, err := os.Stat(mount); err == nil {
paths = append(paths, mount)
seen[mount] = true
m.logger.Printf("[DEBUG] Including app data: %s (from %s)", mount, stackName)
}
}
}
return paths
}
func shouldPrune(schedule string) bool {
loc, err := time.LoadLocation("Europe/Budapest")
if err != nil {
@@ -450,6 +511,18 @@ func (m *Manager) RefreshCache(nextDBDump, nextBackup time.Time) {
status.DiscoveredDBs = dbs
}
// Discover app data (for per-app backup toggles)
if m.stackProvider != nil {
backupPrefs := m.settings.GetAppBackupMap()
status.AppDataInfo = DiscoverAppData(m.stackProvider, m.cfg.Paths.HDDPath, backupPrefs, status.DiscoveredDBs)
// Include enabled app backup paths in the displayed BackupPaths
appPaths := m.resolveAppBackupPaths()
if len(appPaths) > 0 {
status.BackupPaths = append(status.BackupPaths, appPaths...)
}
}
// Cross-check: if LastDBDump results have empty validation but files exist,
// re-validate from disk. This handles controller restarts and race conditions.
if m.lastDBDump != nil && filesErr == nil {
+35
View File
@@ -303,6 +303,41 @@ func (r *ResticManager) Stats() (*RepoStats, error) {
return stats, nil
}
// GetPassword reads and returns the restic repository password.
func (r *ResticManager) GetPassword() (string, error) {
data, err := os.ReadFile(r.passwordFile)
if err != nil {
return "", fmt.Errorf("reading restic password: %w", err)
}
return strings.TrimSpace(string(data)), nil
}
// RestoreAppData restores specific paths from a restic snapshot.
func (r *ResticManager) RestoreAppData(snapshotID string, paths []string) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
args := []string{
"restore", snapshotID,
"--target", "/",
}
for _, p := range paths {
args = append(args, "--include", p)
}
r.logger.Printf("[WARN] RESTORE started: snapshot=%s, paths=%v", snapshotID, paths)
cmd := r.command(ctx, args...)
output, err := cmd.CombinedOutput()
if err != nil {
r.logger.Printf("[ERROR] Restore failed: %v, output: %s", err, truncate(string(output), 500))
return fmt.Errorf("restic restore failed: %w", err)
}
r.logger.Printf("[INFO] RESTORE completed: snapshot=%s, paths=%v", snapshotID, paths)
return nil
}
func (r *ResticManager) command(ctx context.Context, args ...string) *exec.Cmd {
cmd := exec.CommandContext(ctx, "restic", args...)
cmd.Env = append(os.Environ(),
+62
View File
@@ -0,0 +1,62 @@
package backup
import "fmt"
// RestoreApp restores an app's HDD data from a restic snapshot.
func (m *Manager) RestoreApp(stackName, snapshotID string) error {
// Validate app has backup enabled
if !m.settings.IsAppBackupEnabled(stackName) {
return fmt.Errorf("backup not enabled for %s", stackName)
}
// Resolve HDD paths for this app
if m.stackProvider == nil {
return fmt.Errorf("stack provider not configured")
}
hddMounts := m.stackProvider.GetStackHDDMounts(stackName)
if len(hddMounts) == 0 {
return fmt.Errorf("no HDD data paths found for %s", stackName)
}
// Validate snapshot exists
snapshots, err := m.restic.ListSnapshots(100)
if err != nil {
return fmt.Errorf("listing snapshots: %w", err)
}
found := false
for _, s := range snapshots {
if s.ID == snapshotID {
found = true
break
}
}
if !found {
return fmt.Errorf("snapshot %s not found", snapshotID)
}
// Use the running flag to prevent concurrent backup/restore
m.mu.Lock()
if m.running {
m.mu.Unlock()
return fmt.Errorf("backup or restore already in progress")
}
m.running = true
m.mu.Unlock()
defer func() {
m.mu.Lock()
m.running = false
m.mu.Unlock()
}()
m.logger.Printf("[WARN] RESTORE starting: stack=%s, snapshot=%s, paths=%v", stackName, snapshotID, hddMounts)
// Execute restore
if err := m.restic.RestoreAppData(snapshotID, hddMounts); err != nil {
m.logger.Printf("[ERROR] RESTORE failed for %s: %v", stackName, err)
return err
}
m.logger.Printf("[INFO] RESTORE completed: stack=%s, snapshot=%s", stackName, snapshotID)
return nil
}