package stacks import ( "bufio" "context" "fmt" "os" "os/exec" "path/filepath" "strings" "time" ) // DeleteResponse holds the result of a stack deletion (orphan delete). type DeleteResponse struct { Deleted string `json:"deleted"` VolumesRemoved []string `json:"volumes_removed"` HDDPathsRemoved []string `json:"hdd_paths_removed"` HDDPathsPreserved []string `json:"hdd_paths_preserved"` } // RemoveResponse holds the result of removing a deployed (non-orphaned) stack. type RemoveResponse struct { Removed string `json:"removed"` VolumesRemoved []string `json:"volumes_removed"` HDDPathsRemoved []string `json:"hdd_paths_removed"` HDDPathsPreserved []string `json:"hdd_paths_preserved"` BackupPathsRemoved []string `json:"backup_paths_removed,omitempty"` } // BackupDataResponse holds information about backup data associated with a stack. type BackupDataResponse struct { Stack string `json:"stack"` BackupPaths []HDDPath `json:"backup_paths"` // reuses HDDPath (path, size, exists) HasBackups bool `json:"has_backups"` } // 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, "appdata"): true, filepath.Join(hddPath, "backups"): true, filepath.Join(hddPath, "media"): true, filepath.Join(hddPath, "Dokumentumok"): 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 } // RemoveStack removes a deployed (non-orphaned) stack: stops containers, removes // volumes, optionally removes HDD data and backup data, then removes app.yaml // so the stack reverts to "not deployed" state. The template files (docker-compose.yml, // .felhom.yml) are preserved so the user can redeploy. func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemove []string) (*RemoveResponse, error) { // Safety: never remove protected stacks if m.cfg.IsProtectedStack(name) { return nil, fmt.Errorf("stack %q is protected and cannot be removed", name) } stack, ok := m.GetStack(name) if !ok { return nil, fmt.Errorf("stack %q not found", name) } // Must be deployed if !stack.Deployed { return nil, fmt.Errorf("stack %q is not deployed", 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 removing", name) } stackDir := filepath.Dir(stack.ComposePath) hddPath := m.cfg.Paths.HDDPath m.logger.Printf("[INFO] Removing deployed stack: %s (removeHDDData=%v, backupPaths=%d)", name, removeHDDData, len(backupPathsToRemove)) start := time.Now() resp := &RemoveResponse{ Removed: name, } // Step 1: Parse compose file for HDD bind mounts hddMounts := ParseComposeHDDMounts(stack.ComposePath, hddPath) // Step 2: Run docker compose down --volumes (keep images for potential redeploy) env := m.stackEnv(stackDir) output, err := m.composeExecCustomEnv(stackDir, env, "down", "--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 { 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 } if removeHDDData { 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: Handle backup data cleanup for _, bkPath := range backupPathsToRemove { cleanPath := filepath.Clean(bkPath) if _, err := os.Stat(cleanPath); os.IsNotExist(err) { continue } sizeHuman := getDirSizeHuman(cleanPath) if err := os.RemoveAll(cleanPath); err != nil { m.logger.Printf("[ERROR] Failed to remove backup data %s: %v", cleanPath, err) } else { m.logger.Printf("[INFO] Removed backup data: %s (%s)", cleanPath, sizeHuman) resp.BackupPathsRemoved = append(resp.BackupPathsRemoved, fmt.Sprintf("%s (%s)", cleanPath, sizeHuman)) } } // Step 6: Remove app.yaml only (keep template files for redeploy) appYAMLPath := filepath.Join(stackDir, "app.yaml") if err := os.Remove(appYAMLPath); err != nil && !os.IsNotExist(err) { m.logger.Printf("[ERROR] Failed to remove %s: %v", appYAMLPath, err) return resp, fmt.Errorf("failed to remove app.yaml: %w", err) } m.logger.Printf("[INFO] Stack %s removed successfully (took %.1fs)", name, time.Since(start).Seconds()) // Step 7: Update in-memory state and rescan m.mu.Lock() if s, ok := m.stacks[name]; ok { s.Deployed = false s.AppConfig = nil } m.mu.Unlock() if err := m.ScanStacks(); err != nil { m.logger.Printf("[WARN] Rescan after remove failed: %v", err) } return resp, nil } // GetStackBackupData returns information about backup data for a stack. // drivePath is the app's home drive (HDD or system data path). func (m *Manager) GetStackBackupData(name string, drivePath string) (*BackupDataResponse, error) { _, ok := m.GetStack(name) if !ok { return nil, fmt.Errorf("stack %q not found", name) } resp := &BackupDataResponse{ Stack: name, } if drivePath == "" { return resp, nil } // Check DB dump directory: /backups/primary//db-dumps dbDumpPath := filepath.Join(drivePath, "backups", "primary", name, "db-dumps") resp.BackupPaths = append(resp.BackupPaths, buildPathInfo(dbDumpPath)) // Check cross-drive rsync directory: /backups/secondary//rsync rsyncPath := filepath.Join(drivePath, "backups", "secondary", name, "rsync") resp.BackupPaths = append(resp.BackupPaths, buildPathInfo(rsyncPath)) for _, p := range resp.BackupPaths { if p.Exists { resp.HasBackups = true break } } return resp, nil } // buildPathInfo creates an HDDPath with size info for a given path. func buildPathInfo(path string) HDDPath { item := HDDPath{Path: path} info, err := os.Stat(path) if err != nil { item.Exists = false return item } item.Exists = true if info.IsDir() { item.SizeBytes = getDirSizeBytes(path) item.SizeHuman = getDirSizeHuman(path) } return item } // 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 }