package stacks import ( "bufio" "context" "fmt" "os" "os/exec" "path/filepath" "strings" "time" ) // DeleteResponse holds the result of a stack deletion. type DeleteResponse struct { Deleted string `json:"deleted"` VolumesRemoved []string `json:"volumes_removed"` HDDPathsRemoved []string `json:"hdd_paths_removed"` HDDPathsPreserved []string `json:"hdd_paths_preserved"` } // HDDDataResponse holds information about HDD data associated with a stack. type HDDDataResponse struct { Stack string `json:"stack"` HDDPaths []HDDPath `json:"hdd_paths"` HasHDDData bool `json:"has_hdd_data"` } // HDDPath represents a single HDD bind mount path and its status. type HDDPath struct { Path string `json:"path"` SizeBytes int64 `json:"size_bytes"` SizeHuman string `json:"size_human"` Exists bool `json:"exists"` } // ProtectedHDDPaths returns the set of top-level HDD directories that must never be deleted. func ProtectedHDDPaths(hddPath string) map[string]bool { if hddPath == "" { return nil } return map[string]bool{ hddPath: true, filepath.Join(hddPath, "media"): true, filepath.Join(hddPath, "storage"): true, filepath.Join(hddPath, "Dokumentumok"): true, filepath.Join(hddPath, "appdata"): true, } } // DeleteStack removes an orphaned stack: stops containers, removes volumes, // optionally removes HDD data, and deletes the stack directory. func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse, error) { // Safety: never delete protected stacks if m.cfg.IsProtectedStack(name) { return nil, fmt.Errorf("stack %q is protected and cannot be deleted", name) } stack, ok := m.GetStack(name) if !ok { return nil, fmt.Errorf("stack %q not found", name) } // Must be orphaned if !stack.Orphaned { return nil, fmt.Errorf("stack %q is not orphaned — only orphaned stacks can be deleted", name) } // Must be stopped (not running) if stack.State == StateRunning || stack.State == StateStarting || stack.State == StateRestarting { return nil, fmt.Errorf("stack %q is still running — stop it first before deleting", name) } stackDir := filepath.Dir(stack.ComposePath) hddPath := m.cfg.Paths.HDDPath m.logger.Printf("[INFO] Deleting orphaned stack: %s (removeHDDData=%v)", name, removeHDDData) start := time.Now() resp := &DeleteResponse{ Deleted: name, } // Step 1: Parse compose file for HDD bind mounts hddMounts := ParseComposeHDDMounts(stack.ComposePath, hddPath) // Step 2: Run docker compose down --rmi local --volumes // H14: Return error if docker compose down fails — continuing would leave orphaned containers. env := m.stackEnv(stackDir) output, err := m.composeExecCustomEnv(stackDir, env, "down", "--rmi", "local", "--volumes") if err != nil { m.logger.Printf("[ERROR] docker compose down for %s failed: %v (output: %s)", name, err, truncateStr(output, 200)) return resp, fmt.Errorf("docker compose down failed for %s: %w", name, err) } // Step 3: Identify removed volumes from compose output for _, line := range strings.Split(output, "\n") { line = strings.TrimSpace(line) if strings.Contains(line, "Removing volume") || strings.Contains(line, "Volume") { resp.VolumesRemoved = append(resp.VolumesRemoved, line) } } // Step 4: Handle HDD data protected := ProtectedHDDPaths(hddPath) for _, mount := range hddMounts { // Safety: never delete protected top-level dirs cleanPath := filepath.Clean(mount) if protected != nil && protected[cleanPath] { m.logger.Printf("[WARN] Refusing to delete protected HDD path: %s", cleanPath) continue } if _, err := os.Stat(cleanPath); os.IsNotExist(err) { continue // path doesn't exist, nothing to do } if removeHDDData { // Get size before removal sizeHuman := getDirSizeHuman(cleanPath) if err := os.RemoveAll(cleanPath); err != nil { m.logger.Printf("[ERROR] Failed to remove HDD data %s: %v", cleanPath, err) } else { m.logger.Printf("[INFO] Removed HDD data: %s (%s)", cleanPath, sizeHuman) resp.HDDPathsRemoved = append(resp.HDDPathsRemoved, fmt.Sprintf("%s (%s)", cleanPath, sizeHuman)) } } else { sizeHuman := getDirSizeHuman(cleanPath) resp.HDDPathsPreserved = append(resp.HDDPathsPreserved, fmt.Sprintf("%s (%s)", cleanPath, sizeHuman)) } } // Step 5: Remove stack directory if err := os.RemoveAll(stackDir); err != nil { m.logger.Printf("[ERROR] Failed to remove stack directory %s: %v", stackDir, err) return resp, fmt.Errorf("failed to remove stack directory: %w", err) } m.logger.Printf("[INFO] Stack %s deleted successfully (took %.1fs)", name, time.Since(start).Seconds()) // Step 6: Remove from in-memory map and rescan m.mu.Lock() delete(m.stacks, name) m.mu.Unlock() if err := m.ScanStacks(); err != nil { m.logger.Printf("[WARN] Rescan after delete failed: %v", err) } return resp, nil } // GetStackHDDData returns information about HDD bind mounts for a stack. func (m *Manager) GetStackHDDData(name string) (*HDDDataResponse, error) { stack, ok := m.GetStack(name) if !ok { return nil, fmt.Errorf("stack %q not found", name) } hddPath := m.cfg.Paths.HDDPath resp := &HDDDataResponse{ Stack: name, } if hddPath == "" { return resp, nil } mounts := ParseComposeHDDMounts(stack.ComposePath, hddPath) protected := ProtectedHDDPaths(hddPath) for _, mount := range mounts { cleanPath := filepath.Clean(mount) // Skip protected top-level dirs if protected != nil && protected[cleanPath] { continue } hddItem := HDDPath{ Path: cleanPath, } info, err := os.Stat(cleanPath) if err != nil { hddItem.Exists = false } else { hddItem.Exists = true if info.IsDir() { hddItem.SizeBytes = getDirSizeBytes(cleanPath) hddItem.SizeHuman = getDirSizeHuman(cleanPath) } } resp.HDDPaths = append(resp.HDDPaths, hddItem) } resp.HasHDDData = len(resp.HDDPaths) > 0 return resp, nil } // ParseComposeHDDMounts reads a docker-compose.yml and extracts host paths // that reference the HDD path from volume bind mounts. func ParseComposeHDDMounts(composePath, hddPath string) []string { if hddPath == "" { return nil } data, err := os.ReadFile(composePath) if err != nil { return nil } var mounts []string seen := make(map[string]bool) scanner := bufio.NewScanner(strings.NewReader(string(data))) inVolumes := false for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) // Track when we're in a volumes section (service-level, not top-level) if strings.HasPrefix(line, "volumes:") { inVolumes = true continue } if inVolumes && !strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "#") && line != "" { inVolumes = false } if !inVolumes || !strings.HasPrefix(line, "- ") { continue } // Parse bind mount: "- /host/path:/container/path:options" mountStr := strings.TrimPrefix(line, "- ") mountStr = strings.Trim(mountStr, "\"'") parts := strings.SplitN(mountStr, ":", 3) if len(parts) < 2 { continue } hostPath := parts[0] // Resolve ${HDD_PATH} variable reference hostPath = strings.ReplaceAll(hostPath, "${HDD_PATH}", hddPath) // C10: Clean path BEFORE prefix check to prevent traversal like ${HDD_PATH}/../../etc/passwd. cleanPath := filepath.Clean(hostPath) cleanHDD := filepath.Clean(hddPath) // Check if this is an HDD mount (must be cleanHDD itself or a direct subpath) if cleanPath != cleanHDD && !strings.HasPrefix(cleanPath, cleanHDD+string(filepath.Separator)) { continue } if !seen[cleanPath] { seen[cleanPath] = true mounts = append(mounts, cleanPath) } } return mounts } // getDirSizeHuman returns a human-readable size string for a directory using du. func getDirSizeHuman(path string) string { cmd := exec.Command("du", "-sh", path) output, err := cmd.Output() if err != nil { return "unknown" } fields := strings.Fields(string(output)) if len(fields) > 0 { return fields[0] } return "unknown" } // getDirSizeBytes returns the total size in bytes for a directory. func getDirSizeBytes(path string) int64 { 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 { var size int64 if n, _ := fmt.Sscanf(fields[0], "%d", &size); n != 1 { return 0 } return size } return 0 }