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:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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'});
|
||||
|
||||
Reference in New Issue
Block a user