Files
deploy-felhom-compose/controller/internal/backup/appdata.go
T
admin d160c6c06d v0.12.4 — 15 bug fixes (CRITICAL/HIGH/MEDIUM)
CRITICAL:
- C1: SetAppBackupBulk data loss + nil map panic (settings.go)
- C2: UpdateStackConfig nil Env map panic (deploy.go)
- C3: ValidateDump missing scanner.Err() check (dbdump.go)

HIGH:
- H1: nextDailyRun DST bug — use time.Date(day+1) not Add(24h)
- H2: Cache Europe/Budapest timezone with sync.Once in scheduler
- H3: settings.save() leaks .tmp file on WriteFile failure
- H4: SetNotificationPrefs nil pointer panic
- H5: appDirSize + getDirSizeBytes ignore Sscanf return value
- H6: getDirSizeBytes has no timeout — add 30s context
- H7: dbdump.go tmpFile not using defer Close
- H8: UpdateCrossDriveStatus misleading comment

MEDIUM:
- M1: Replace custom containsBytes with strings.Contains
- M2: scheduler.Every() validates interval > 0
- M3: executeJob panic recovery now sets LastRun
- M4: logPostStartStatus copies env slice before goroutine
- M5: Cache timezone in web package via getTimezone() sync.Once

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-18 07:50:02 +01:00

174 lines
4.3 KiB
Go

package backup
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
"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
StopStack(name string) error
StartStack(name string) error
}
// 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
StorageLabel string // resolved from registered storage paths
}
// 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, 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, path.SizeHuman = appDirSize(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]
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
}
// appDirSize returns the total byte count and a human-readable string for a directory.
// H2/H3: Single du invocation with 30s timeout replaces two separate calls.
func appDirSize(path string) (int64, string) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "du", "-sb", path)
output, err := cmd.Output()
if err != nil {
return 0, "?"
}
fields := strings.Fields(string(output))
if len(fields) == 0 {
return 0, "?"
}
var size int64
if n, _ := fmt.Sscanf(fields[0], "%d", &size); n != 1 {
return 0, "?"
}
return size, humanizeBytes(size)
}
// 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)
}
}