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 GetStackHDDPath(name string) string // raw HDD_PATH from app.yaml (empty if no HDD) 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. // All apps with HDD data are backed up automatically (mandatory — no opt-in). func DiscoverAppData(provider StackDataProvider, 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 } } // All apps with HDD data are backed up automatically (mandatory) info.BackupEnabled = info.HasHDDData 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) } }