diff --git a/internal/checks/disk_check.go b/internal/checks/disk_check.go index 4a2e535..75782f6 100644 --- a/internal/checks/disk_check.go +++ b/internal/checks/disk_check.go @@ -1,3 +1,6 @@ +//go:build !windows && !openbsd && !netbsd +// +build !windows,!openbsd,!netbsd + package checks import ( @@ -6,18 +9,6 @@ import ( "syscall" ) -// DiskSpaceCheck represents disk space information -type DiskSpaceCheck struct { - Path string - TotalBytes uint64 - AvailableBytes uint64 - UsedBytes uint64 - UsedPercent float64 - Sufficient bool - Warning bool - Critical bool -} - // CheckDiskSpace checks available disk space for a given path func CheckDiskSpace(path string) *DiskSpaceCheck { // Get absolute path @@ -37,9 +28,9 @@ func CheckDiskSpace(path string) *DiskSpaceCheck { } } - // Calculate space - totalBytes := stat.Blocks * uint64(stat.Bsize) - availableBytes := stat.Bavail * uint64(stat.Bsize) + // Calculate space (handle different types on different platforms) + totalBytes := uint64(stat.Blocks) * uint64(stat.Bsize) + availableBytes := uint64(stat.Bavail) * uint64(stat.Bsize) usedBytes := totalBytes - availableBytes usedPercent := float64(usedBytes) / float64(totalBytes) * 100 @@ -146,16 +137,4 @@ func EstimateBackupSize(databaseSize uint64, compressionLevel int) uint64 { -// formatBytes formats bytes to human-readable format -func formatBytes(bytes uint64) string { - const unit = 1024 - if bytes < unit { - return fmt.Sprintf("%d B", bytes) - } - div, exp := uint64(unit), 0 - for n := bytes / unit; n >= unit; n /= unit { - div *= unit - exp++ - } - return fmt.Sprintf("%.1f %ciB", float64(bytes)/float64(div), "KMGTPE"[exp]) -} + diff --git a/internal/checks/disk_check_bsd.go b/internal/checks/disk_check_bsd.go new file mode 100644 index 0000000..859affe --- /dev/null +++ b/internal/checks/disk_check_bsd.go @@ -0,0 +1,111 @@ +//go:build openbsd || netbsd +// +build openbsd netbsd + +package checks + +import ( + "fmt" + "path/filepath" + "syscall" +) + +// CheckDiskSpace checks available disk space for a given path (OpenBSD/NetBSD implementation) +func CheckDiskSpace(path string) *DiskSpaceCheck { + // Get absolute path + absPath, err := filepath.Abs(path) + if err != nil { + absPath = path + } + + // Get filesystem stats + var stat syscall.Statfs_t + if err := syscall.Statfs(absPath, &stat); err != nil { + // Return error state + return &DiskSpaceCheck{ + Path: absPath, + Critical: true, + Sufficient: false, + } + } + + // Calculate space (OpenBSD/NetBSD use different field names) + totalBytes := uint64(stat.F_blocks) * uint64(stat.F_bsize) + availableBytes := uint64(stat.F_bavail) * uint64(stat.F_bsize) + usedBytes := totalBytes - availableBytes + usedPercent := float64(usedBytes) / float64(totalBytes) * 100 + + check := &DiskSpaceCheck{ + Path: absPath, + TotalBytes: totalBytes, + AvailableBytes: availableBytes, + UsedBytes: usedBytes, + UsedPercent: usedPercent, + } + + // Determine status thresholds + check.Critical = usedPercent >= 95 + check.Warning = usedPercent >= 80 && !check.Critical + check.Sufficient = !check.Critical && !check.Warning + + return check +} + +// CheckDiskSpaceForRestore checks if there's enough space for restore (needs 4x archive size) +func CheckDiskSpaceForRestore(path string, archiveSize int64) *DiskSpaceCheck { + check := CheckDiskSpace(path) + requiredBytes := uint64(archiveSize) * 4 // Account for decompression + + // Override status based on required space + if check.AvailableBytes < requiredBytes { + check.Critical = true + check.Sufficient = false + check.Warning = false + } else if check.AvailableBytes < requiredBytes*2 { + check.Warning = true + check.Sufficient = false + } + + return check +} + +// FormatDiskSpaceMessage creates a user-friendly disk space message +func FormatDiskSpaceMessage(check *DiskSpaceCheck) string { + var status string + var icon string + + if check.Critical { + status = "CRITICAL" + icon = "❌" + } else if check.Warning { + status = "WARNING" + icon = "⚠️ " + } else { + status = "OK" + icon = "✓" + } + + msg := fmt.Sprintf(`📊 Disk Space Check (%s): + Path: %s + Total: %s + Available: %s (%.1f%% used) + %s Status: %s`, + status, + check.Path, + formatBytes(check.TotalBytes), + formatBytes(check.AvailableBytes), + check.UsedPercent, + icon, + status) + + if check.Critical { + msg += "\n \n ⚠️ CRITICAL: Insufficient disk space!" + msg += "\n Operation blocked. Free up space before continuing." + } else if check.Warning { + msg += "\n \n ⚠️ WARNING: Low disk space!" + msg += "\n Backup may fail if database is larger than estimated." + } else { + msg += "\n \n ✓ Sufficient space available" + } + + return msg +} \ No newline at end of file diff --git a/internal/checks/disk_check_windows.go b/internal/checks/disk_check_windows.go new file mode 100644 index 0000000..3eac2ba --- /dev/null +++ b/internal/checks/disk_check_windows.go @@ -0,0 +1,131 @@ +//go:build windows +// +build windows + +package checks + +import ( + "fmt" + "path/filepath" + "syscall" + "unsafe" +) + +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + getDiskFreeSpaceEx = kernel32.NewProc("GetDiskFreeSpaceExW") +) + +// CheckDiskSpace checks available disk space for a given path (Windows implementation) +func CheckDiskSpace(path string) *DiskSpaceCheck { + // Get absolute path + absPath, err := filepath.Abs(path) + if err != nil { + absPath = path + } + + // Get the drive root (e.g., "C:\") + vol := filepath.VolumeName(absPath) + if vol == "" { + // If no volume, try current directory + vol = "." + } + + var freeBytesAvailable, totalNumberOfBytes, totalNumberOfFreeBytes uint64 + + // Call Windows API + pathPtr, _ := syscall.UTF16PtrFromString(vol) + ret, _, _ := getDiskFreeSpaceEx.Call( + uintptr(unsafe.Pointer(pathPtr)), + uintptr(unsafe.Pointer(&freeBytesAvailable)), + uintptr(unsafe.Pointer(&totalNumberOfBytes)), + uintptr(unsafe.Pointer(&totalNumberOfFreeBytes))) + + if ret == 0 { + // API call failed, return error state + return &DiskSpaceCheck{ + Path: absPath, + Critical: true, + Sufficient: false, + } + } + + // Calculate usage + usedBytes := totalNumberOfBytes - totalNumberOfFreeBytes + usedPercent := float64(usedBytes) / float64(totalNumberOfBytes) * 100 + + check := &DiskSpaceCheck{ + Path: absPath, + TotalBytes: totalNumberOfBytes, + AvailableBytes: freeBytesAvailable, + UsedBytes: usedBytes, + UsedPercent: usedPercent, + } + + // Determine status thresholds + check.Critical = usedPercent >= 95 + check.Warning = usedPercent >= 80 && !check.Critical + check.Sufficient = !check.Critical && !check.Warning + + return check +} + +// CheckDiskSpaceForRestore checks if there's enough space for restore (needs 4x archive size) +func CheckDiskSpaceForRestore(path string, archiveSize int64) *DiskSpaceCheck { + check := CheckDiskSpace(path) + requiredBytes := uint64(archiveSize) * 4 // Account for decompression + + // Override status based on required space + if check.AvailableBytes < requiredBytes { + check.Critical = true + check.Sufficient = false + check.Warning = false + } else if check.AvailableBytes < requiredBytes*2 { + check.Warning = true + check.Sufficient = false + } + + return check +} + +// FormatDiskSpaceMessage creates a user-friendly disk space message +func FormatDiskSpaceMessage(check *DiskSpaceCheck) string { + var status string + var icon string + + if check.Critical { + status = "CRITICAL" + icon = "❌" + } else if check.Warning { + status = "WARNING" + icon = "⚠️ " + } else { + status = "OK" + icon = "✓" + } + + msg := fmt.Sprintf(`📊 Disk Space Check (%s): + Path: %s + Total: %s + Available: %s (%.1f%% used) + %s Status: %s`, + status, + check.Path, + formatBytes(check.TotalBytes), + formatBytes(check.AvailableBytes), + check.UsedPercent, + icon, + status) + + if check.Critical { + msg += "\n \n ⚠️ CRITICAL: Insufficient disk space!" + msg += "\n Operation blocked. Free up space before continuing." + } else if check.Warning { + msg += "\n \n ⚠️ WARNING: Low disk space!" + msg += "\n Backup may fail if database is larger than estimated." + } else { + msg += "\n \n ✓ Sufficient space available" + } + + return msg +} + diff --git a/internal/checks/types.go b/internal/checks/types.go new file mode 100644 index 0000000..5c2c1a9 --- /dev/null +++ b/internal/checks/types.go @@ -0,0 +1,29 @@ +package checks + +import "fmt" + +// DiskSpaceCheck represents disk space information +type DiskSpaceCheck struct { + Path string + TotalBytes uint64 + AvailableBytes uint64 + UsedBytes uint64 + UsedPercent float64 + Sufficient bool + Warning bool + Critical bool +} + +// formatBytes formats bytes to human-readable format +func formatBytes(bytes uint64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := uint64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} \ No newline at end of file diff --git a/internal/cleanup/processes.go b/internal/cleanup/processes.go index 14366a4..eb58fe0 100644 --- a/internal/cleanup/processes.go +++ b/internal/cleanup/processes.go @@ -1,3 +1,6 @@ +//go:build !windows +// +build !windows + package cleanup import ( diff --git a/internal/cleanup/processes_windows.go b/internal/cleanup/processes_windows.go new file mode 100644 index 0000000..759fd4e --- /dev/null +++ b/internal/cleanup/processes_windows.go @@ -0,0 +1,117 @@ +//go:build windows +// +build windows + +package cleanup + +import ( + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "syscall" + + "dbbackup/internal/logger" +) + +// KillOrphanedProcesses finds and kills any orphaned pg_dump, pg_restore, gzip, or pigz processes (Windows implementation) +func KillOrphanedProcesses(log logger.Logger) error { + processNames := []string{"pg_dump.exe", "pg_restore.exe", "gzip.exe", "pigz.exe", "gunzip.exe"} + + myPID := os.Getpid() + var killed []string + var errors []error + + for _, procName := range processNames { + pids, err := findProcessesByNameWindows(procName, myPID) + if err != nil { + log.Warn("Failed to search for processes", "process", procName, "error", err) + continue + } + + for _, pid := range pids { + if err := killProcessWindows(pid); err != nil { + errors = append(errors, fmt.Errorf("failed to kill %s (PID %d): %w", procName, pid, err)) + } else { + killed = append(killed, fmt.Sprintf("%s (PID %d)", procName, pid)) + } + } + } + + if len(killed) > 0 { + log.Info("Cleaned up orphaned processes", "count", len(killed), "processes", strings.Join(killed, ", ")) + } + + if len(errors) > 0 { + return fmt.Errorf("some processes could not be killed: %v", errors) + } + + return nil +} + +// findProcessesByNameWindows returns PIDs of processes matching the given name (Windows implementation) +func findProcessesByNameWindows(name string, excludePID int) ([]int, error) { + // Use tasklist command for Windows + cmd := exec.Command("tasklist", "/FO", "CSV", "/NH", "/FI", fmt.Sprintf("IMAGENAME eq %s", name)) + output, err := cmd.Output() + if err != nil { + // No processes found or command failed + return []int{}, nil + } + + var pids []int + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + for _, line := range lines { + if line == "" { + continue + } + + // Parse CSV output: "name","pid","session","mem" + fields := strings.Split(line, ",") + if len(fields) < 2 { + continue + } + + // Remove quotes from PID field + pidStr := strings.Trim(fields[1], `"`) + pid, err := strconv.Atoi(pidStr) + if err != nil { + continue + } + + // Don't kill our own process + if pid == excludePID { + continue + } + + pids = append(pids, pid) + } + + return pids, nil +} + +// killProcessWindows kills a process on Windows +func killProcessWindows(pid int) error { + // Use taskkill command + cmd := exec.Command("taskkill", "/F", "/PID", strconv.Itoa(pid)) + return cmd.Run() +} + +// SetProcessGroup sets up process group for Windows (no-op, Windows doesn't use Unix process groups) +func SetProcessGroup(cmd *exec.Cmd) { + // Windows doesn't support Unix-style process groups + // We can set CREATE_NEW_PROCESS_GROUP flag instead + cmd.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP, + } +} + +// KillCommandGroup kills a command on Windows +func KillCommandGroup(cmd *exec.Cmd) error { + if cmd.Process == nil { + return nil + } + + // On Windows, just kill the process directly + return cmd.Process.Kill() +} \ No newline at end of file