package models import ( "fmt" "os" "os/exec" "path/filepath" "strings" "gopkg.in/yaml.v3" ) var composeFileNames = []string{ "docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml", } // Host paths excluded from backup folder deduction. var excludedPrefixes = []string{"/var/run/", "/proc/", "/sys/", "/dev/", "/run/", "/etc/"} var excludedExact = []string{ "/var/run/docker.sock", "/etc/timezone", "/etc/localtime", "/etc/hosts", "/etc/hostname", "/etc/resolv.conf", } // ScanAll discovers compose-based services under all default paths plus an optional // extra folder. Existing services matched by compose file path are updated (status, // and backup folder if previously unset). New services are created. func ScanAll(extraFolder string) ScanResult { paths := resolveScanPaths(extraFolder) result := ScanResult{ ScannedPaths: paths, Created: []ServiceScanInfo{}, Updated: []ServiceScanInfo{}, Errors: []string{}, } for _, p := range paths { r := scanFolder(p) result.Found += r.Found result.Created = append(result.Created, r.Created...) result.Updated = append(result.Updated, r.Updated...) result.Errors = append(result.Errors, r.Errors...) } return result } // resolveScanPaths returns the directories to scan: /opt/compose, /root/compose, // /home/*/compose, and any explicitly supplied extra path. func resolveScanPaths(extra string) []string { var paths []string for _, candidate := range []string{"/opt/compose", "/root/compose"} { if isAccessibleDir(candidate) { paths = append(paths, candidate) } } if entries, err := os.ReadDir("/home"); err == nil { for _, e := range entries { if !e.IsDir() { continue } p := filepath.Join("/home", e.Name(), "compose") if isAccessibleDir(p) { paths = append(paths, p) } } } if extra != "" && isAccessibleDir(extra) { paths = append(paths, extra) } return paths } func isAccessibleDir(path string) bool { info, err := os.Stat(path) return err == nil && info.IsDir() } // scanFolder scans the immediate subdirectories of folder for compose files. func scanFolder(folder string) ScanResult { result := ScanResult{Created: []ServiceScanInfo{}, Updated: []ServiceScanInfo{}} entries, err := os.ReadDir(folder) if err != nil { result.Errors = []string{fmt.Sprintf("cannot read %s: %v", folder, err)} return result } for _, entry := range entries { if !entry.IsDir() { continue } serviceDir := filepath.Join(folder, entry.Name()) composeFile := findComposeFile(serviceDir) if composeFile == "" { continue } result.Found++ info, err := processComposeFile(entry.Name(), composeFile) if err != nil { result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", composeFile, err)) continue } created, isNew := upsertService(info) if created { if isNew { result.Created = append(result.Created, info) } else { result.Updated = append(result.Updated, info) } } } return result } func findComposeFile(dir string) string { for _, name := range composeFileNames { p := filepath.Join(dir, name) if _, err := os.Stat(p); err == nil { return p } } return "" } // processComposeFile parses a compose file and produces a ServiceScanInfo. func processComposeFile(name, composeFile string) (ServiceScanInfo, error) { hostPaths, err := extractHostPaths(composeFile) if err != nil { return ServiceScanInfo{}, err } return ServiceScanInfo{ Name: name, ComposeFile: composeFile, BackupFolder: deduceBackupFolder(hostPaths, name), Status: probeServiceStatus(composeFile), }, nil } // extractHostPaths parses all volume bind-mount host paths from a compose YAML file, // filtering out system/runtime paths that should not be backed up. // If yaml.v3 strict parsing fails (e.g. duplicate mapping keys in real-world compose // files), it falls back to a lenient text-based extraction. func extractHostPaths(composeFile string) ([]string, error) { data, err := os.ReadFile(composeFile) if err != nil { return nil, err } var doc map[string]interface{} if err := yaml.Unmarshal(data, &doc); err != nil { // Malformed YAML (duplicate keys etc.) — fall back to text scan. return extractHostPathsFallback(data), nil } servicesRaw, ok := doc["services"] if !ok { return nil, nil } services, _ := servicesRaw.(map[string]interface{}) seen := map[string]bool{} var paths []string for _, svcRaw := range services { svc, ok := svcRaw.(map[string]interface{}) if !ok { continue } volumes, _ := svc["volumes"].([]interface{}) for _, v := range volumes { var hostPath string switch vv := v.(type) { case string: hostPath = hostPathFromShortForm(vv) case map[string]interface{}: if t, _ := vv["type"].(string); t == "bind" { hostPath, _ = vv["source"].(string) } } hostPath = filepath.Clean(hostPath) if hostPath != "" && hostPath != "." && isBackupablePath(hostPath) && !seen[hostPath] { seen[hostPath] = true paths = append(paths, hostPath) } } } return paths, nil } // extractHostPathsFallback uses simple line scanning to extract volume bind-mounts // from compose files that fail strict YAML parsing (duplicate keys, etc.). // It looks for list items that contain an absolute host path followed by a colon. func extractHostPathsFallback(data []byte) []string { seen := map[string]bool{} var paths []string for _, line := range strings.Split(string(data), "\n") { trimmed := strings.TrimSpace(line) // Volume list entries: "- /abs/path:/container/path" (with or without quotes) if !strings.HasPrefix(trimmed, "- ") { continue } spec := strings.Trim(strings.TrimPrefix(trimmed, "- "), "\"' ") if !strings.HasPrefix(spec, "/") { continue } hostPath := hostPathFromShortForm(spec) if hostPath == "" { continue } hostPath = filepath.Clean(hostPath) if isBackupablePath(hostPath) && !seen[hostPath] { seen[hostPath] = true paths = append(paths, hostPath) } } return paths } // hostPathFromShortForm extracts the host path from "host:container[:opts]" volume spec. func hostPathFromShortForm(spec string) string { parts := strings.SplitN(spec, ":", 3) if len(parts) < 2 { return "" } src := parts[0] if !filepath.IsAbs(src) { return "" // named volume or relative path } return src } // isBackupablePath returns false for system/runtime paths that should not be backed up. func isBackupablePath(path string) bool { for _, exact := range excludedExact { if path == exact { return false } } for _, prefix := range excludedPrefixes { if strings.HasPrefix(path, prefix) { return false } } return true } // deduceBackupFolder finds the most appropriate backup root for a service. // // Priority: // 1. /opt/data/containers/ — standard layout in the examples // 2. Longest common directory prefix across all collected host paths // 3. First collected path as-is func deduceBackupFolder(hostPaths []string, serviceName string) string { if len(hostPaths) == 0 { return "" } // 1. Standard layout standard := "/opt/data/containers/" + serviceName for _, p := range hostPaths { if strings.HasPrefix(p, standard) { return standard } } // 2. Common prefix prefix := commonPathPrefix(hostPaths) if prefix != "" && prefix != "/" { return prefix } // 3. Fallback return hostPaths[0] } // commonPathPrefix returns the longest common directory prefix shared by all paths. func commonPathPrefix(paths []string) string { if len(paths) == 0 { return "" } if len(paths) == 1 { return paths[0] } sep := string(filepath.Separator) parts := strings.Split(paths[0], sep) for _, p := range paths[1:] { other := strings.Split(p, sep) limit := len(parts) if len(other) < limit { limit = len(other) } i := 0 for i < limit && parts[i] == other[i] { i++ } parts = parts[:i] } if len(parts) <= 1 { return "/" } return strings.Join(parts, sep) } // probeServiceStatus checks whether the service has any running containers. // Uses the docker project label set by compose, then falls back to compose CLI. func probeServiceStatus(composeFile string) string { projectName := strings.ToLower(filepath.Base(filepath.Dir(composeFile))) // Method 1: docker ps with project label (no compose binary required) cmd := exec.Command("docker", "ps", "-q", "--filter", "label=com.docker.compose.project="+projectName) if out, err := cmd.Output(); err == nil { if strings.TrimSpace(string(out)) != "" { return "up" } return "down" } // Method 2: docker compose ps cmd = exec.Command("docker", "compose", "-f", composeFile, "ps", "-q") if out, err := cmd.Output(); err == nil { if strings.TrimSpace(string(out)) != "" { return "up" } return "down" } // Method 3: legacy docker-compose cmd = exec.Command("docker-compose", "-f", composeFile, "ps", "-q") if out, err := cmd.Output(); err == nil { if strings.TrimSpace(string(out)) != "" { return "up" } return "down" } return "unknown" } // upsertService creates or updates a service entry indexed by compose file path. // Returns (true, true) = created; (true, false) = updated; (false, _) = no change. func upsertService(info ServiceScanInfo) (bool, bool) { services := GetAllServices() for _, svc := range services { if svc.ComposeFile != info.ComposeFile { continue } // Found — update status and backup folder if previously unset changed := svc.Status != info.Status if svc.BackupFolder == "" && info.BackupFolder != "" { svc.BackupFolder = info.BackupFolder changed = true } svc.Status = info.Status if changed { _, _ = UpdateService(svc.ID, svc) return true, false } return false, false } // Not found — create _, _ = AddService(Service{ Name: info.Name, ComposeFile: info.ComposeFile, BackupFolder: info.BackupFolder, Status: info.Status, ComposeType: "docker compose", }) return true, true }