d160c6c06d
CRITICAL: - C1: SetAppBackupBulk data loss + nil map panic (settings.go) - C2: UpdateStackConfig nil Env map panic (deploy.go) - C3: ValidateDump missing scanner.Err() check (dbdump.go) HIGH: - H1: nextDailyRun DST bug — use time.Date(day+1) not Add(24h) - H2: Cache Europe/Budapest timezone with sync.Once in scheduler - H3: settings.save() leaks .tmp file on WriteFile failure - H4: SetNotificationPrefs nil pointer panic - H5: appDirSize + getDirSizeBytes ignore Sscanf return value - H6: getDirSizeBytes has no timeout — add 30s context - H7: dbdump.go tmpFile not using defer Close - H8: UpdateCrossDriveStatus misleading comment MEDIUM: - M1: Replace custom containsBytes with strings.Contains - M2: scheduler.Every() validates interval > 0 - M3: executeJob panic recovery now sets LastRun - M4: logPostStartStatus copies env slice before goroutine - M5: Cache timezone in web package via getTimezone() sync.Once Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
299 lines
8.4 KiB
Go
299 lines
8.4 KiB
Go
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
|
|
}
|