diff --git a/controller/configs/controller.yaml.example b/controller/configs/controller.yaml.example index 32844ec..2604ee5 100644 --- a/controller/configs/controller.yaml.example +++ b/controller/configs/controller.yaml.example @@ -53,6 +53,7 @@ stacks: - "traefik" - "cloudflared" - "felhom-controller" + - "filebrowser" update_window: "03:00-05:00" compose_command: "" diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index 58c084a..40d4c41 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -83,6 +83,14 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { case hasSuffix(path, "/logs") && req.Method == http.MethodGet: r.getStackLogs(w, req, extractName(path, "/logs")) + // GET /api/stacks/{name}/hdd-data + case hasSuffix(path, "/hdd-data") && req.Method == http.MethodGet: + r.getStackHDDData(w, req, extractName(path, "/hdd-data")) + + // DELETE /api/stacks/{name} + case strings.HasPrefix(path, "/stacks/") && req.Method == http.MethodDelete && !hasSubpath(path, "/stacks/"): + r.deleteStack(w, req, trimSegment(path, "/stacks/")) + // POST /api/sync — trigger immediate catalog sync case path == "/sync" && req.Method == http.MethodPost: r.triggerSync(w, req) @@ -250,6 +258,45 @@ func (r *Router) getStackLogs(w http.ResponseWriter, req *http.Request, name str writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"logs": output}}) } +func (r *Router) getStackHDDData(w http.ResponseWriter, _ *http.Request, name string) { + resp, err := r.stackMgr.GetStackHDDData(name) + if err != nil { + writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()}) + return + } + writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp}) +} + +func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name string) { + r.logger.Printf("[API] Delete requested for stack: %s", name) + + var body struct { + RemoveHDDData bool `json:"remove_hdd_data"` + } + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + body.RemoveHDDData = false + } + + resp, err := r.stackMgr.DeleteStack(name, body.RemoveHDDData) + if err != nil { + r.logger.Printf("[API] Delete failed for %s: %v", name, err) + status := http.StatusInternalServerError + if strings.Contains(err.Error(), "protected") { + status = http.StatusForbidden + } + if strings.Contains(err.Error(), "not found") { + status = http.StatusNotFound + } + if strings.Contains(err.Error(), "not orphaned") || strings.Contains(err.Error(), "still running") { + status = http.StatusConflict + } + writeJSON(w, status, apiResponse{OK: false, Error: err.Error()}) + return + } + + writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp, Message: "Stack " + name + " deleted"}) +} + func (r *Router) triggerSync(w http.ResponseWriter, _ *http.Request) { r.logger.Println("[API] Manual catalog sync requested") result := r.syncer.TriggerSync() diff --git a/controller/internal/stacks/delete.go b/controller/internal/stacks/delete.go new file mode 100644 index 0000000..4e3210b --- /dev/null +++ b/controller/internal/stacks/delete.go @@ -0,0 +1,290 @@ +package stacks + +import ( + "bufio" + "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 + env := m.stackEnv(stackDir) + output, err := m.composeExecCustomEnv(stackDir, env, "down", "--rmi", "local", "--volumes") + if err != nil { + m.logger.Printf("[WARN] docker compose down for %s had errors: %v (output: %s)", name, err, truncateStr(output, 200)) + // Continue anyway — the stack dir will be removed + } + + // 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) + + // Check if this is an HDD mount + if !strings.HasPrefix(hostPath, hddPath) { + continue + } + + cleanPath := filepath.Clean(hostPath) + 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 { + cmd := exec.Command("du", "-sb", path) + output, err := cmd.Output() + if err != nil { + return 0 + } + fields := strings.Fields(string(output)) + if len(fields) > 0 { + var size int64 + fmt.Sscanf(fields[0], "%d", &size) + return size + } + return 0 +} diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index d1cefe1..5ecd1f6 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -29,6 +29,7 @@ const ( StatePaused ContainerState = "paused" StateUnknown ContainerState = "unknown" StateNotDeployed ContainerState = "not_deployed" + StateOrphaned ContainerState = "orphaned" ) // ContainerInfo holds status info about a single container within a stack. @@ -47,6 +48,7 @@ type Stack struct { State ContainerState `json:"state"` Deployed bool `json:"deployed"` // Has app.yaml with deployed=true Protected bool `json:"protected"` + Orphaned bool `json:"orphaned"` // Deployed but no catalog template Containers []ContainerInfo `json:"containers"` AppConfig *AppConfig `json:"app_config,omitempty"` LastUpdated time.Time `json:"last_updated"` @@ -166,6 +168,25 @@ func (m *Manager) ScanStacks() error { } } + // Detect orphaned stacks (deployed but no longer in catalog) + catalogTemplates := m.getCatalogTemplateSlugs() + if catalogTemplates != nil { + orphanCount := 0 + for _, stack := range m.stacks { + if stack.Protected || !stack.Deployed { + stack.Orphaned = false + continue + } + stack.Orphaned = !catalogTemplates[stack.Name] + if stack.Orphaned { + orphanCount++ + } + } + if orphanCount > 0 { + m.logger.Printf("[INFO] Detected %d orphaned stack(s)", orphanCount) + } + } + deployedCount := 0 for _, s := range m.stacks { if s.Deployed { @@ -733,4 +754,25 @@ func (m *Manager) CommittedMemory() (requestMB int, limitMB int) { limitMB += ParseMemoryMB(s.Meta.Resources.MemLimit) } return +} + +// getCatalogTemplateSlugs reads the synced catalog cache and returns a set of +// template slugs (directory names) that have a docker-compose.yml. +func (m *Manager) getCatalogTemplateSlugs() map[string]bool { + cacheDir := filepath.Join(m.cfg.Paths.DataDir, "catalog-cache", "templates") + entries, err := os.ReadDir(cacheDir) + if err != nil { + m.logger.Printf("[WARN] Cannot read catalog cache for orphan detection: %v", err) + return nil + } + slugs := make(map[string]bool, len(entries)) + for _, e := range entries { + if e.IsDir() { + composePath := filepath.Join(cacheDir, e.Name(), "docker-compose.yml") + if _, err := os.Stat(composePath); err == nil { + slugs[e.Name()] = true + } + } + } + return slugs } \ No newline at end of file diff --git a/controller/internal/web/templates.go b/controller/internal/web/templates.go index 9eaf384..d1f4ce7 100644 --- a/controller/internal/web/templates.go +++ b/controller/internal/web/templates.go @@ -117,6 +117,84 @@ const layoutTmpl = ` btn.classList.remove('loading'); } } + async function deleteOrphanStack(name) { + var modal = document.createElement('div'); + modal.className = 'modal-overlay'; + modal.id = 'delete-modal'; + modal.innerHTML = '
Ez a művelet eltávolítja a konténereket, a köteteket és a konfigurációs fájlokat.
' + + 'Nem sikerült lekérni az adatokat: ' + err.message + '
' + + ''; + } + } + function closeDeleteModal() { + var modal = document.getElementById('delete-modal'); + if (modal) modal.remove(); + } + async function confirmDelete(name) { + var btn = document.getElementById('confirm-delete-btn'); + var checkbox = document.getElementById('delete-hdd-check'); + var removeHDD = checkbox ? checkbox.checked : false; + btn.disabled = true; + btn.textContent = 'Törlés folyamatban...'; + try { + var resp = await fetch('/api/stacks/' + name, { + method: 'DELETE', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({remove_hdd_data: removeHDD}) + }); + var data = await resp.json(); + if (data.ok) { + var modal = document.getElementById('delete-modal'); + var removedInfo = ''; + if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) { + removedInfo = 'Törölt adatok: ' + data.data.hdd_paths_removed.join(', ') + '
'; + } + var preservedInfo = ''; + if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) { + preservedInfo = 'Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '
'; + } + modal.querySelector('.modal-card').innerHTML = + 'Az alkalmazás (' + name + ') törölve lett.
' + + removedInfo + preservedInfo + + ''; + } else { + alert('Hiba: ' + (data.error || 'Ismeretlen hiba')); + btn.disabled = false; + btn.textContent = 'Törlés'; + } + } catch (err) { + alert('Hálózati hiba: ' + err.message); + btn.disabled = false; + btn.textContent = 'Törlés'; + } + }