feat: format empty partitions on system disk (v0.32.6)

Detect and offer to format empty (no filesystem) partitions on the system
disk. Adds IsSystemPartition() for granular per-partition safety checks
instead of blocking the entire system disk. Init wizard shows formatable
partitions with appropriate warnings. Add felhotest demo node to docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 16:54:16 +01:00
parent 2c0064ac87
commit b4bda38fa1
11 changed files with 270 additions and 39 deletions
+19 -6
View File
@@ -61,12 +61,25 @@ func FormatAndMount(req FormatRequest, progress chan<- FormatProgress) (string,
return "", fail("validating", "Az eszköz nem létezik: "+req.DevicePath, err)
}
isSystem, err := IsSystemDisk(req.DevicePath)
if err != nil {
return "", fail("validating", "Rendszermeghajtó ellenőrzése sikertelen", err)
}
if isSystem {
return "", fail("validating", "Ez a rendszermeghajtó — nem formázható!", fmt.Errorf("device is system disk"))
if req.CreatePartition {
// Whole-disk operation: block if any partition on this disk is a system partition.
isSystem, err := IsSystemDisk(req.DevicePath)
if err != nil {
return "", fail("validating", "Rendszermeghajtó ellenőrzése sikertelen", err)
}
if isSystem {
return "", fail("validating", "Ez a rendszermeghajtó — nem formázható!", fmt.Errorf("device is system disk"))
}
} else {
// Partition-only operation: block only if THIS specific partition is a system partition.
// Allows formatting empty partitions on the system disk (e.g., dedicated data partition).
isSysPart, err := IsSystemPartition(req.DevicePath)
if err != nil {
return "", fail("validating", "Rendszerpartíció ellenőrzése sikertelen", err)
}
if isSysPart {
return "", fail("validating", "Ez rendszerpartíció — nem formázható!", fmt.Errorf("partition is a system partition"))
}
}
mounted, err := IsDeviceMounted(req.DevicePath)
@@ -5,6 +5,7 @@ package storage
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
@@ -46,6 +47,68 @@ func IsSystemDisk(devicePath string) (bool, error) {
return rootDiskGroup == devDiskGroup, nil
}
// IsSystemPartition checks if a specific partition is a system partition
// (/, /boot, /boot/efi, swap) or is currently mounted.
// Unlike IsSystemDisk() which blocks the entire disk, this checks only the
// individual partition — allowing non-system partitions on system disks to be formatted.
func IsSystemPartition(partitionPath string) (bool, error) {
fstabPath := "/host-fstab"
if _, err := os.Stat(fstabPath); err != nil {
fstabPath = "/etc/fstab"
}
data, err := os.ReadFile(fstabPath)
if err != nil {
// If we can't read fstab, err on the side of caution
return true, fmt.Errorf("cannot read fstab: %w", err)
}
systemMounts := map[string]bool{"/": true, "/boot": true, "/boot/efi": true}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
source := fields[0]
mountPoint := fields[1]
fsType := fields[2]
if !systemMounts[mountPoint] && fsType != "swap" {
continue
}
var devPath string
if strings.HasPrefix(source, "UUID=") {
uuid := strings.TrimPrefix(source, "UUID=")
if out, err := exec.Command("blkid", "-U", uuid).Output(); err == nil {
devPath = strings.TrimSpace(string(out))
}
} else if strings.HasPrefix(source, "/dev/") {
devPath = source
}
if devPath == partitionPath {
return true, nil
}
}
// Also check if the partition is currently mounted
mounted, err := IsDeviceMounted(partitionPath)
if err != nil {
return false, err
}
if mounted {
return true, nil
}
return false, nil
}
// IsDeviceMounted checks if a device or any of its partitions is currently mounted.
func IsDeviceMounted(devicePath string) (bool, error) {
data, err := os.ReadFile("/proc/mounts")
@@ -8,6 +8,10 @@ func IsSystemDisk(devicePath string) (bool, error) {
return false, fmt.Errorf("storage init is only supported on Linux")
}
func IsSystemPartition(partitionPath string) (bool, error) {
return false, fmt.Errorf("storage init is only supported on Linux")
}
func IsDeviceMounted(devicePath string) (bool, error) {
return false, fmt.Errorf("storage init is only supported on Linux")
}
+12 -2
View File
@@ -25,8 +25,18 @@ type Partition struct {
MountPoint string // "" if not mounted
}
// FormatablePartition is an empty partition on a system disk that can be formatted.
type FormatablePartition struct {
Partition
ParentDiskName string // "sda"
ParentDiskPath string // "/dev/sda"
ParentDiskModel string // "Samsung SSD 870"
ParentDiskSize string // "500 GB"
}
// ScanResult from disk detection.
type ScanResult struct {
AvailableDisks []BlockDevice // Unmounted, non-system disks
SystemDisks []BlockDevice // System/mounted disks (display only)
AvailableDisks []BlockDevice // Unmounted, non-system disks
SystemDisks []BlockDevice // System/mounted disks (display only)
FormatablePartitions []FormatablePartition // Empty partitions on system disks, safe to format
}
+76 -1
View File
@@ -164,6 +164,58 @@ func getSystemDiskNames() map[string]bool {
return systemDisks
}
// getSystemPartitionPaths returns the set of partition device paths (e.g., "/dev/sda3")
// that are system partitions (/, /boot, /boot/efi, swap).
// Unlike getSystemDiskNames() which returns parent disk names, this returns the actual
// partition paths for granular checks.
func getSystemPartitionPaths() map[string]bool {
sysPartitions := map[string]bool{}
fstabPath := "/host-fstab"
if _, err := os.Stat(fstabPath); err != nil {
fstabPath = "/etc/fstab"
}
data, err := os.ReadFile(fstabPath)
if err != nil {
return sysPartitions
}
systemMounts := map[string]bool{"/": true, "/boot": true, "/boot/efi": true}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
source := fields[0]
mountPoint := fields[1]
fsType := fields[2]
if !systemMounts[mountPoint] && fsType != "swap" {
continue
}
if strings.HasPrefix(source, "UUID=") {
uuid := strings.TrimPrefix(source, "UUID=")
if out, err := exec.Command("blkid", "-U", uuid).Output(); err == nil {
devPath := strings.TrimSpace(string(out))
if devPath != "" {
sysPartitions[devPath] = true
}
}
} else if strings.HasPrefix(source, "/dev/") {
sysPartitions[source] = true
}
}
return sysPartitions
}
// partitionToParentDisk extracts the parent disk name from a partition device path.
// "/dev/sda2" → "sda", "/dev/nvme0n1p2" → "nvme0n1", "/dev/mmcblk0p1" → "mmcblk0"
func partitionToParentDisk(devPath string) string {
@@ -344,8 +396,31 @@ func ScanDisks(logger *log.Logger, debug bool) (*ScanResult, error) {
enrichWithBlkid(result.AvailableDisks, logger, debug)
enrichWithBlkid(result.SystemDisks, logger, debug)
// Detect formatable partitions on system disks:
// empty (no filesystem), unmounted, and NOT a system partition.
sysPartitionPaths := getSystemPartitionPaths()
for _, sysDisk := range result.SystemDisks {
for _, part := range sysDisk.Partitions {
if part.FSType != "" || part.MountPoint != "" {
continue
}
if sysPartitionPaths[part.Path] {
continue
}
result.FormatablePartitions = append(result.FormatablePartitions, FormatablePartition{
Partition: part,
ParentDiskName: sysDisk.Name,
ParentDiskPath: sysDisk.Path,
ParentDiskModel: sysDisk.Model,
ParentDiskSize: sysDisk.Size,
})
dbg("formatable partition found: %s on system disk %s", part.Path, sysDisk.Name)
}
}
if logger != nil {
logger.Printf("[INFO] [storage] Found %d disks", len(result.AvailableDisks)+len(result.SystemDisks))
logger.Printf("[INFO] [storage] Found %d disks, %d formatable partitions",
len(result.AvailableDisks)+len(result.SystemDisks), len(result.FormatablePartitions))
}
dbg("disk scan completed in %s", time.Since(scanStart).Round(time.Millisecond))