package appbackup import ( "bufio" "context" "fmt" "log" "os" "os/exec" "path/filepath" "strings" "time" "gopkg.in/yaml.v3" ) // StackDataProvider provides stack data to the backup packages 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) GetDockerVolumes(name string) []string // full Docker volume names (project-prefixed) StopStack(name string) error StartStack(name string) error RefreshAndIsRunning(name string) bool // GetStackRecoveryInfo returns the data needed to capture a SECRET-FREE recovery unit // (Phase 2): the stack dir, pinned image tags, the non-secret env, and the NAMES of the // secret/data-key env vars (values are NEVER returned — they are recovered at restore time // from the guest's own app.yaml, live or via the PBS whole-guest snapshot). ok=false if the // stack is unknown. GetStackRecoveryInfo(name string) (RecoveryInfo, bool) // --- Phase 2b: restore-from-recovery-unit --- // RecoverStackSecrets returns the live decrypted values for the named secret env vars that are // currently present (non-empty) in the stack's app.yaml (the guest's own — live rootfs, or // PBS-restored). Names that are absent/empty are simply omitted from the map; the caller's // fail-closed gate decides what to do. The unit is never the source of secrets. RecoverStackSecrets(name string, names []string) map[string]string // RecreateStackFromUnit restores an app's definition from the unit's compose dir into the stack // dir, writes app.yaml from fullEnv (encrypting secret fields), and (re-)deploys it via // `docker compose up -d`, which re-pulls the pinned image. Secrets are NEVER regenerated. RecreateStackFromUnit(name, composeSrcDir string, fullEnv map[string]string) error } // RecoveryInfo carries everything needed to write a secret-free recovery unit for a stack. // It deliberately holds NO secret values — only the names of secret/data-key env vars, so the // manifest can record what must be recovered from elsewhere (guest app.yaml / PBS) without the // unit ever storing a secret or a data-encrypting key. type RecoveryInfo struct { StackDir string // dir holding docker-compose.yml + .felhom.yml + app.yaml DisplayName string // app display name ImagePins []string // pinned image tags from compose `image:` lines (re-pulled on restore) NonSecretEnv map[string]string // env with all secret/password/data-key values removed (plaintext only) SecretEnvVars []string // NAMES of stripped secret/password fields (recovered from guest/PBS) DataKeyEnvVars []string // NAMES of data-encrypting-key fields (fail-closed gate on restore) } // ParseComposeImages extracts the pinned image references (`image: repo:tag`) from a // docker-compose.yml, in file order, de-duplicated. The image bytes are never stored in the // recovery unit — only these pins, so restore re-pulls from the registry. func ParseComposeImages(composePath string) []string { data, err := os.ReadFile(composePath) if err != nil { return nil } var images []string seen := make(map[string]bool) scanner := bufio.NewScanner(strings.NewReader(string(data))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if !strings.HasPrefix(line, "image:") { continue } img := strings.TrimSpace(strings.TrimPrefix(line, "image:")) img = strings.Trim(img, "\"'") // Skip variable-only images we can't pin (e.g. image: ${SOME_IMAGE}) if img == "" || strings.HasPrefix(img, "${") { continue } if !seen[img] { seen[img] = true images = append(images, img) } } return images } // StackSummary holds minimal stack info needed for app data discovery. type StackSummary struct { Name string DisplayName string ComposePath string NeedsHDD bool HasVolumes 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 HasVolumeData 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) info.HasVolumeData = len(info.DockerVolumes) > 0 // 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) } log.Printf("[INFO] [backup] Discovered app data: %d apps", len(result)) 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 } // ResolveDockerVolumeNames returns full Docker volume names with the compose project prefix. // Docker Compose V2 creates volumes as _ where project = directory name. func ResolveDockerVolumeNames(composePath string) []string { vols := ParseComposeNamedVolumes(composePath) if len(vols) == 0 { return nil } project := filepath.Base(filepath.Dir(composePath)) names := make([]string, 0, len(vols)) for _, v := range vols { names = append(names, project+"_"+v.Name) } return names } // 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. // Exported so the backup package can forward to it (shared helper). func HumanizeBytes(b int64) string { return humanizeBytes(b) } // 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) } }