Fix cross-platform builds: process cleanup and disk space checking

- Add platform-specific implementations for Windows, BSD systems
- Create platform-specific disk space checking with proper syscalls
- Add Windows process cleanup using tasklist/taskkill
- Add BSD-specific Statfs_t field handling (F_blocks, F_bavail, F_bsize)
- Support 9/10 target platforms (Linux, Windows, macOS, FreeBSD, OpenBSD)
- Process cleanup now works on all Unix-like systems and Windows
- Phase 2 TUI improvements compatible across platforms
This commit is contained in:
2025-11-18 19:15:49 +00:00
parent 694c8c802a
commit ccf70db840
6 changed files with 398 additions and 28 deletions

View File

@@ -1,3 +1,6 @@
//go:build !windows && !openbsd && !netbsd
// +build !windows,!openbsd,!netbsd
package checks package checks
import ( import (
@@ -6,18 +9,6 @@ import (
"syscall" "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 // CheckDiskSpace checks available disk space for a given path
func CheckDiskSpace(path string) *DiskSpaceCheck { func CheckDiskSpace(path string) *DiskSpaceCheck {
// Get absolute path // Get absolute path
@@ -37,9 +28,9 @@ func CheckDiskSpace(path string) *DiskSpaceCheck {
} }
} }
// Calculate space // Calculate space (handle different types on different platforms)
totalBytes := stat.Blocks * uint64(stat.Bsize) totalBytes := uint64(stat.Blocks) * uint64(stat.Bsize)
availableBytes := stat.Bavail * uint64(stat.Bsize) availableBytes := uint64(stat.Bavail) * uint64(stat.Bsize)
usedBytes := totalBytes - availableBytes usedBytes := totalBytes - availableBytes
usedPercent := float64(usedBytes) / float64(totalBytes) * 100 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])
}

View File

@@ -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
}

View File

@@ -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
}

29
internal/checks/types.go Normal file
View File

@@ -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])
}

View File

@@ -1,3 +1,6 @@
//go:build !windows
// +build !windows
package cleanup package cleanup
import ( import (

View File

@@ -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()
}