Files
deploy-felhom-compose/controller/internal/system/mounts_linux.go
T
admin 1de244646b v0.12.0 — Backup page overhaul: unified app rows, bug fixes, sequential chaining
Bug fixes:
- GetFullStatus() returns deep copy; CrossDriveSummary/UnconfiguredApps/CrossDriveWarnings
  are always nil in the copy so the handler builds them fresh (fixes duplicate-apps bug)
- Replace binary IsMountPoint check with tiered CheckBackupDestination() — path-not-exist,
  not-writable, system-drive (warning), disk >90-95% full; shown as warning vs critical
- Remove dead settingsAppBackupHandler / POST /settings/app-backup route (toggle wrote
  to settings.json but nothing consumed the flag)

Architecture:
- Unified per-app backup rows: new AppBackupRow struct + buildAppBackupRows() replaces
  the two old sections with expandable rows showing all 3 layers per app
- Sequential backup chaining: cross-drive runs immediately after restic (removed
  independent cross-drive-daily/cross-drive-weekly scheduler jobs)
- Deploy page: remove "Csak kézi indítás" schedule option; add weekly consistency note

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-17 17:56:28 +01:00

222 lines
6.2 KiB
Go

//go:build linux
package system
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
)
// IsMountPoint checks if a path is on a different device than its parent.
// Returns true if the path is a mount point (different device ID from parent).
func IsMountPoint(path string) bool {
var pathStat, parentStat syscall.Stat_t
if err := syscall.Stat(path, &pathStat); err != nil {
return false
}
parent := filepath.Dir(path)
if err := syscall.Stat(parent, &parentStat); err != nil {
return false
}
return pathStat.Dev != parentStat.Dev
}
// IsWritable checks if the given path is writable by attempting to create+remove a temp file.
func IsWritable(path string) bool {
testFile := filepath.Join(path, ".felhom-write-test")
f, err := os.Create(testFile)
if err != nil {
return false
}
f.Close()
os.Remove(testFile)
return true
}
// PathsOverlap returns true if one path is a parent or child of the other.
func PathsOverlap(a, b string) bool {
a = filepath.Clean(a)
b = filepath.Clean(b)
if a == b {
return true
}
aSep := a + string(os.PathSeparator)
bSep := b + string(os.PathSeparator)
return strings.HasPrefix(aSep, bSep) || strings.HasPrefix(bSep, aSep)
}
// DiskUsageInfo holds disk usage statistics for a path.
type DiskUsageInfo struct {
TotalGB float64
UsedGB float64
AvailGB float64
UsedPercent float64
TotalHuman string
UsedHuman string
}
// GetDiskUsage returns disk usage info for a path, or nil on error.
func GetDiskUsage(path string) *DiskUsageInfo {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return nil
}
bsize := uint64(stat.Bsize)
total := stat.Blocks * bsize
avail := stat.Bavail * bsize
used := total - (stat.Bfree * bsize)
const gb = 1024 * 1024 * 1024
info := &DiskUsageInfo{
TotalGB: float64(total) / float64(gb),
UsedGB: float64(used) / float64(gb),
AvailGB: float64(avail) / float64(gb),
}
if total > 0 {
info.UsedPercent = float64(used) / float64(total) * 100
}
info.TotalHuman = formatGB(info.TotalGB)
info.UsedHuman = formatGB(info.UsedGB)
return info
}
func formatGB(gb float64) string {
if gb >= 1000 {
return fmt.Sprintf("%.1f TB", gb/1024)
}
return fmt.Sprintf("%.1f GB", gb)
}
// FSInfo holds filesystem type, device, and disk model info.
type FSInfo struct {
FSType string // "ext4", "btrfs"
Device string // "/dev/sda1"
Model string // "WD Elements 25A2" (best-effort from sysfs)
}
// GetFSInfo returns filesystem info for a path using findmnt, or nil on error.
func GetFSInfo(path string) *FSInfo {
out, err := exec.Command("findmnt", "-n", "-o", "SOURCE,FSTYPE", "--target", path).Output()
if err != nil {
return nil
}
fields := strings.Fields(strings.TrimSpace(string(out)))
if len(fields) < 2 {
return nil
}
info := &FSInfo{
Device: fields[0],
FSType: fields[1],
}
// Try to get disk model from sysfs
info.Model = diskModel(info.Device)
return info
}
// DestinationHealth holds the result of a tiered backup destination check.
type DestinationHealth struct {
Exists bool
Writable bool
MountPoint bool // true if path is on a different device from its parent
SystemDrive bool // true if path is on the same device as /
UsedPercent float64 // disk usage percentage (0 if unknown)
FreeGB float64
Warning string // human-readable warning message in Hungarian (empty = ok)
Blocked bool // if true, backup must not run
Severity string // "ok", "warning", "critical"
}
// CheckBackupDestination performs tiered validation of a cross-drive backup destination.
// Returns a DestinationHealth describing any issues found.
func CheckBackupDestination(path string) DestinationHealth {
h := DestinationHealth{Severity: "ok"}
// Tier 1: path must exist
if _, err := os.Stat(path); os.IsNotExist(err) {
h.Warning = "A cél tárhely (" + path + ") nem létezik!"
h.Blocked = true
h.Severity = "critical"
return h
}
h.Exists = true
// Tier 2: path must be writable
if !IsWritable(path) {
h.Warning = "A cél tárhely (" + path + ") nem írható! Ellenőrizd a jogosultságokat."
h.Blocked = true
h.Severity = "critical"
return h
}
h.Writable = true
// Tier 3: detect if source and destination are on the same block device
// (stronger than IsMountPoint — catches e.g. bind mounts within same device)
if isSameBlockDevice(path, "/") {
h.SystemDrive = true
// This is a warning, not a block — user data still protected against software errors
h.Warning = "A cél tárhely (" + path + ") a rendszermeghajtón van. " +
"Meghajtóhiba esetén az eredeti adat és a mentés is elveszhet. " +
"Külső meghajtó használata javasolt."
h.Severity = "warning"
// Don't return early — also check disk usage
} else {
h.MountPoint = true
}
// Tier 4: disk usage checks
if di := GetDiskUsage(path); di != nil {
h.UsedPercent = di.UsedPercent
h.FreeGB = di.AvailGB
if di.UsedPercent >= 95 {
h.Warning = fmt.Sprintf("A mentési meghajtó megtelt (%.0f%% használt)!", di.UsedPercent)
h.Blocked = true
h.Severity = "critical"
} else if di.UsedPercent >= 90 && h.Severity == "ok" {
h.Warning = fmt.Sprintf("A mentési meghajtó majdnem megtelt (%.0f%% használt).", di.UsedPercent)
h.Severity = "warning"
}
}
return h
}
// isSameBlockDevice returns true if pathA and pathB are on the same block device.
func isSameBlockDevice(pathA, pathB string) bool {
var statA, statB syscall.Stat_t
if err := syscall.Stat(pathA, &statA); err != nil {
return false
}
if err := syscall.Stat(pathB, &statB); err != nil {
return false
}
return statA.Dev == statB.Dev
}
// diskModel reads the disk model from /sys/block/<dev>/device/model.
func diskModel(device string) string {
// /dev/sda1 → sda, /dev/nvme0n1p1 → nvme0n1
base := filepath.Base(device)
// Strip partition number: sda1 → sda, nvme0n1p1 → nvme0n1
disk := base
if strings.HasPrefix(base, "nvme") {
// nvme0n1p1 → find last 'p' followed by digits
if idx := strings.LastIndex(base, "p"); idx > 4 {
disk = base[:idx]
}
} else {
// sda1 → sda: strip trailing digits
disk = strings.TrimRight(base, "0123456789")
}
modelPath := "/sys/block/" + disk + "/device/model"
data, err := os.ReadFile(modelPath)
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}