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
+6 -3
View File
@@ -4,7 +4,7 @@
A single, lightweight Go container that replaces Portainer + scattered systemd scripts with a unified, Hungarian-language web dashboard for managing Docker Compose stacks, backups, storage, monitoring, and notifications on customer hardware.
**Current version: v0.32.5**
**Current version: v0.32.6**
---
@@ -472,7 +472,7 @@ The storage subsystem handles the full lifecycle of external storage: detection,
- `ScanDisks()` uses `lsblk -J -b` for block device enumeration
- System disk detection via host fstab parsing (`/host-fstab`) + UUID resolution via `blkid`
- Partitions enriched with filesystem type, UUID, and label from direct `blkid` probing (Docker containers have incomplete udev cache)
- Returns `AvailableDisks` (non-system, non-loop, non-CDROM) and `SystemDisks` separately
- Returns `AvailableDisks` (non-system, non-loop, non-CDROM), `SystemDisks`, and `FormatablePartitions` (empty partitions on system disks that are safe to format)
- Handles NVMe (`nvme0n1p1`), SCSI (`sdb1`), and eMMC (`mmcblk0p1`) naming
#### Disk Initialization Wizard (`internal/storage/format.go`)
@@ -488,6 +488,8 @@ A step-by-step UI at `/settings/storage/init`:
Safety guards: system disk detection, mount path conflict check, confirmation required, progress channel for real-time UI feedback.
**System-disk partition formatting:** When the system disk has an empty partition (no filesystem, not mounted, not used for /, /boot, /boot/efi, or swap), the init wizard detects it via `FormatablePartitions` in the scan result and offers to format just that partition. Uses `IsSystemPartition()` (granular per-partition check via fstab) instead of `IsSystemDisk()` (whole-disk block), so sda1 can be formatted while sda3 (root) remains protected.
#### Attach Existing Drive Wizard (`internal/storage/attach.go`)
A step-by-step UI at `/settings/storage/attach` for drives that already have a filesystem (e.g., a previously used ext4 drive). Unlike the init wizard, this does **not** format the drive — existing data is preserved.
@@ -1785,7 +1787,8 @@ See `docker-compose.yml` for the full volume configuration.
| Node | Hardware | Domain | Status |
|------|----------|--------|--------|
| demo-felhom | Acemagic GK3PLUS N100, 16G RAM, 512G SSD + 1TB HDD | demo-felhom.eu | Controller v0.22.3 |
| demo-felhom | Acemagic GK3PLUS N100, 16G RAM, 512G SSD + 1TB HDD | demo-felhom.eu | Active |
| felhotest | Remote VPS, 200G + 100G disk | router.abonet.hu:33022 | Active |
| pi-customer-1 | Raspberry Pi 3B+, 1G RAM, 32G SD | pi-customer-1.local | Not yet tested |
## Related Repositories
+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))
+5 -4
View File
@@ -214,10 +214,11 @@ func (s *Server) storageScanAPIHandler(w http.ResponseWriter, r *http.Request) {
s.logger.Printf("[DEBUG] [web] storageScan: found %d available disks, %d system disks", len(result.AvailableDisks), len(result.SystemDisks))
}
jsonResponse(w, map[string]interface{}{
"ok": true,
"available": result.AvailableDisks,
"system": result.SystemDisks,
"available_count": len(result.AvailableDisks),
"ok": true,
"available": result.AvailableDisks,
"system": result.SystemDisks,
"available_count": len(result.AvailableDisks),
"formatable_partitions": result.FormatablePartitions,
})
}
@@ -146,27 +146,53 @@ function scanDisks() {
function renderScanResult(data) {
var availEl = document.getElementById('available-disks');
var sysEl = document.getElementById('system-disks-note');
var hasAvail = data.available && data.available.length > 0;
var hasFP = data.formatable_partitions && data.formatable_partitions.length > 0;
if (!data.available || data.available.length === 0) {
availEl.innerHTML = '<div class="empty-state" style="padding:1rem">Nem található inicializálható meghajtó.</div>';
if (!hasAvail && !hasFP) {
availEl.innerHTML = '<div class="empty-state" style="padding:1rem">Nem található inicializálható meghajtó vagy partíció.</div>';
} else {
var html = '<h4 style="margin-bottom:.75rem">Talált meghajtók (' + data.available.length + '):</h4>';
data.available.forEach(function(disk) {
var partInfo = '';
if (disk.Partitions && disk.Partitions.length > 0) {
partInfo = disk.Partitions.map(function(p) {
return p.Name + (p.FSType ? ' (' + p.FSType + ')' : ' (nincs fájlrendszer)') + (p.MountPoint ? ' → ' + p.MountPoint : '');
}).join(', ');
}
html += '<div class="storage-path-item" style="cursor:pointer;border:2px solid transparent" ' +
'onclick="selectDisk(this, \'' + disk.Path + '\', ' + JSON.stringify(disk.CreatePartition !== false) + ')" ' +
'data-path="' + disk.Path + '" id="disk-' + disk.Name + '">' +
'<div class="storage-path-header"><div class="storage-path-info">' +
'<span class="storage-path-label">' + disk.Path + '' + (disk.Size || '?') + '</span>' +
(disk.Model ? '<span class="storage-path-path">' + disk.Model + '</span>' : '') +
(partInfo ? '<span class="form-hint">' + partInfo + '</span>' : '<span class="form-hint">Nincs partíció</span>') +
'</div></div></div>';
});
var html = '';
if (hasAvail) {
html += '<h4 style="margin-bottom:.75rem">Talált meghajtók (' + data.available.length + '):</h4>';
data.available.forEach(function(disk) {
var partInfo = '';
if (disk.Partitions && disk.Partitions.length > 0) {
partInfo = disk.Partitions.map(function(p) {
return p.Name + (p.FSType ? ' (' + p.FSType + ')' : ' (nincs fájlrendszer)') + (p.MountPoint ? ' → ' + p.MountPoint : '');
}).join(', ');
}
html += '<div class="storage-path-item" style="cursor:pointer;border:2px solid transparent" ' +
'onclick="selectDisk(this, \'' + disk.Path + '\', true)" ' +
'data-path="' + disk.Path + '" id="disk-' + disk.Name + '">' +
'<div class="storage-path-header"><div class="storage-path-info">' +
'<span class="storage-path-label">' + disk.Path + ' — ' + (disk.Size || '?') + '</span>' +
(disk.Model ? '<span class="storage-path-path">' + disk.Model + '</span>' : '') +
(partInfo ? '<span class="form-hint">' + partInfo + '</span>' : '<span class="form-hint">Nincs partíció</span>') +
'</div></div></div>';
});
}
if (hasFP) {
html += '<h4 style="margin-bottom:.75rem;margin-top:1.5rem">Formázható partíciók a rendszermeghajtón (' +
data.formatable_partitions.length + '):</h4>';
html += '<div class="alert alert-info" style="margin-bottom:.75rem;font-size:.85rem">' +
'Az alábbi partíciók a rendszermeghajtón találhatók, de nincsenek használatban. ' +
'Formázás után adattárolóként használhatók.</div>';
data.formatable_partitions.forEach(function(fp) {
var parentInfo = fp.ParentDiskPath + ' (' + fp.ParentDiskSize + ')';
if (fp.ParentDiskModel) parentInfo += ' — ' + fp.ParentDiskModel;
html += '<div class="storage-path-item" style="cursor:pointer;border:2px solid transparent" ' +
'onclick="selectDisk(this, \'' + fp.Path + '\', false)" ' +
'data-path="' + fp.Path + '">' +
'<div class="storage-path-header"><div class="storage-path-info">' +
'<span class="storage-path-label">○ ' + fp.Path + ' — ' + (fp.Size || '?') + '</span>' +
'<span class="form-hint">Rendszermeghajtó partíciója: ' + parentInfo + '</span>' +
'<span class="form-hint">Nincs fájlrendszer — formázásra kész</span>' +
'</div></div></div>';
});
}
availEl.innerHTML = html;
}
@@ -192,6 +218,20 @@ function selectDisk(el, path, needsPartition) {
document.getElementById('create-partition').value = needsPartition ? 'true' : 'false';
document.getElementById('selected-device-display').textContent = path;
// Update warning text based on whole-disk vs partition-only operation
var warningEl = document.querySelector('#wizard-configure .alert-error');
if (needsPartition) {
warningEl.innerHTML = '<strong>⚠️ FIGYELEM:</strong> A meghajtó <strong>ÖSSZES</strong> adata törlődik!<br>' +
'Ez a művelet <strong>NEM vonható vissza.</strong>';
} else {
warningEl.innerHTML = '<strong>⚠️ FIGYELEM:</strong> A partíció formázva lesz, a rajta lévő adatok törlődnek!<br>' +
'A rendszermeghajtó többi partíciója <strong>NEM</strong> érintett.';
}
// Show/hide the partitioning progress step
var partStep = document.getElementById('pstep-partitioning');
if (partStep) partStep.style.display = needsPartition ? '' : 'none';
// Show configure step
document.getElementById('wizard-configure').style.display = 'block';
document.getElementById('wizard-configure').scrollIntoView({behavior:'smooth'});