diff --git a/CHANGELOG.md b/CHANGELOG.md index e3669b7..cc3afdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ ## Changelog +### v0.32.6 — Format empty partitions on system disk (2026-02-27) + +#### Added +- **storage/scan.go**: New `FormatablePartition` struct and `FormatablePartitions` field on `ScanResult` — detects empty (no filesystem), unmounted, non-system partitions on system disks +- **storage/scan_linux.go**: New `getSystemPartitionPaths()` resolves actual system partition device paths from fstab (more granular than `getSystemDiskNames()` which returns parent disk names). `ScanDisks()` now populates `FormatablePartitions` after enrichment +- **storage/safety_linux.go**: New `IsSystemPartition()` — checks if a specific partition is a system partition (/, /boot, /boot/efi, swap) or is currently mounted; more granular than `IsSystemDisk()` which blocks the entire disk +- **web/storage_handlers.go**: Scan API response now includes `formatable_partitions` array +- **web/templates/storage_init.html**: Init wizard shows formatable system-disk partitions as a separate selectable section with info banner, conditional warning text, and hidden partitioning progress step + +#### Changed +- **storage/format_linux.go**: `FormatAndMount()` now uses `IsSystemPartition()` for partition-only operations (`CreatePartition=false`) instead of `IsSystemDisk()` — allows formatting empty data partitions on the system disk while still blocking system partitions + ### v0.32.5 — USB badge fix + graceful Tier2 backup on disconnected/inactive/removed destinations (2026-02-27) #### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 22864a9..41fe5d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,6 +36,7 @@ Claude in Chrome extension is available — can be used to test web UI on demo-f | **Local (this machine)** | Windows 11 | — | Development, Claude Code runs here. Repos in `E:\git\` | | **Build server (k3s, infra)** | Debian 13 | 192.168.0.180 | Build + push container images, k3s cluster | | **Demo node** | Debian 13 | 192.168.0.162 | Test deployment (demo-felhom.eu) | +| **Demo node 2** | Debian 13 | router.abonet.hu:33022 | Remote test deployment | ## Workspace layout @@ -101,12 +102,14 @@ substitute the full path manually. |------|----|----|------|------| | Build server | Debian 13 | 192.168.0.180 | kisfenyo | Build + push container images | | Demo node | Debian 13 | 192.168.0.162 | kisfenyo | Test deployment (demo-felhom.eu) | +| Demo node 2 | Debian 13 | router.abonet.hu (SSH port 33022) | kisfenyo | Remote test deployment | ## Test environments | Node | OS | Hardware | Domain | IP | Notes | |------|-----|----------|--------|----|-------| | demo-felhom | Debian 13 | Acemagic N100, 16G RAM, 512G SSD + 1TB HDD | demo-felhom.eu | 192.168.0.162 | Primary test node, Cloudflare Tunnel | +| felhotest | Debian 13 | Remote VPS, 200G + 100G disk | — | router.abonet.hu:33022 | Remote test node | | pi-customer-1 | Debian 13 | Raspberry Pi 3B+, 1G RAM, 32G SD | pi-customer-1.local | 192.168.0.161 | Secondary test, not yet active | - Pi-hole DNS on local network forwards `*.demo-felhom.eu` → 192.168.0.162 @@ -152,21 +155,27 @@ The build script: - Pushes to `gitea.dooplex.hu/admin/felhom-controller:` - Expects the version as first argument (e.g., `0.2.11`) -### Step 3: Deploy on the demo node +### Step 3: Deploy on demo nodes ```bash +# Demo node 1 (local) $SSH kisfenyo@192.168.0.162 "cd /opt/docker/felhom-controller && sudo docker pull gitea.dooplex.hu/admin/felhom-controller: && sudo sed -i 's|image: gitea.dooplex.hu/admin/felhom-controller:.*|image: gitea.dooplex.hu/admin/felhom-controller:|' docker-compose.yml && sudo docker compose up -d" + +# Demo node 2 (remote) +$SSH -p 33022 kisfenyo@router.abonet.hu "cd /opt/docker/felhom-controller && sudo docker pull gitea.dooplex.hu/admin/felhom-controller: && sudo sed -i 's|image: gitea.dooplex.hu/admin/felhom-controller:.*|image: gitea.dooplex.hu/admin/felhom-controller:|' docker-compose.yml && sudo docker compose up -d" ``` ### Step 4: Verify the deployment ```bash $SSH kisfenyo@192.168.0.162 "docker ps --filter name=felhom-controller --format '{{.Image}} {{.Status}}'" +$SSH -p 33022 kisfenyo@router.abonet.hu "docker ps --filter name=felhom-controller --format '{{.Image}} {{.Status}}'" ``` Should show the new version and "Up" status. Also check logs for startup errors: ```bash $SSH kisfenyo@192.168.0.162 "docker logs felhom-controller --tail 20" +$SSH -p 33022 kisfenyo@router.abonet.hu "docker logs felhom-controller --tail 20" ``` ### Build workflow summary @@ -176,8 +185,9 @@ $SSH kisfenyo@192.168.0.162 "docker logs felhom-controller --tail 20" | 0. Set SSH var | `SSH=/c/Windows/System32/OpenSSH/ssh.exe` | Local (once per session) | | 1. Commit + push | `git add -A && git commit -m "..." && git push` | Local (this repo) | | 2. Build + push image | `$SSH kisfenyo@192.168.0.180 "cd ~/build/felhom-controller... ./build.sh --push"` | Build server | -| 3. Deploy | `$SSH kisfenyo@192.168.0.162 "... docker compose up -d"` | Demo node | -| 4. Verify | `$SSH kisfenyo@192.168.0.162 "docker ps ..."` | Demo node | +| 3. Deploy (node 1) | `$SSH kisfenyo@192.168.0.162 "... docker compose up -d"` | Demo node | +| 3b. Deploy (node 2) | `$SSH -p 33022 kisfenyo@router.abonet.hu "... docker compose up -d"` | Demo node 2 | +| 4. Verify | `$SSH kisfenyo@192.168.0.162 "docker ps ..."` + same for router.abonet.hu | Both nodes | ### Build & deploy workflow — Hub (felhom-hub) diff --git a/CONTEXT.md b/CONTEXT.md index 28241ed..a07e6e4 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -40,7 +40,7 @@ Last updated: 2026-02-19 (session 59) - **v0.11.8:** ✅ COMPLETE — Per-App Cross-Drive Backup (3-2-1 rule): rsync/restic to secondary drive, deploy page UI, backup page summary, scheduler jobs, API endpoints - **v0.11.9:** ✅ COMPLETE — UI Polish Fixes: spacing, tooltip on "Módszer", status dot instead of disabled checkbox, progressive disclosure, emoji cleanup - **First app deployed:** Paperless-ngx on demo-felhom.eu (2026-02-13) -- **Running on:** demo-felhom (N100 mini PC) at 192.168.0.162:8080 +- **Running on:** demo-felhom (N100 mini PC) at 192.168.0.162:8080, felhotest (remote VPS) at router.abonet.hu:33022 - **All Phase 1-5 features working:** deploy, start/stop/restart/update, logs, health-aware states, auth, monitoring, backups, backup detail page, system monitoring page, settings page ## Architecture decisions diff --git a/controller/README.md b/controller/README.md index 69232ea..531615a 100644 --- a/controller/README.md +++ b/controller/README.md @@ -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 diff --git a/controller/internal/storage/format_linux.go b/controller/internal/storage/format_linux.go index 7071779..d2e7e1a 100644 --- a/controller/internal/storage/format_linux.go +++ b/controller/internal/storage/format_linux.go @@ -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) diff --git a/controller/internal/storage/safety_linux.go b/controller/internal/storage/safety_linux.go index fc71f4e..aaa461d 100644 --- a/controller/internal/storage/safety_linux.go +++ b/controller/internal/storage/safety_linux.go @@ -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") diff --git a/controller/internal/storage/safety_other.go b/controller/internal/storage/safety_other.go index 2d9fef9..d6fa079 100644 --- a/controller/internal/storage/safety_other.go +++ b/controller/internal/storage/safety_other.go @@ -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") } diff --git a/controller/internal/storage/scan.go b/controller/internal/storage/scan.go index 4430c40..ded930e 100644 --- a/controller/internal/storage/scan.go +++ b/controller/internal/storage/scan.go @@ -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 } diff --git a/controller/internal/storage/scan_linux.go b/controller/internal/storage/scan_linux.go index 3b96394..e0316d2 100644 --- a/controller/internal/storage/scan_linux.go +++ b/controller/internal/storage/scan_linux.go @@ -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)) diff --git a/controller/internal/web/storage_handlers.go b/controller/internal/web/storage_handlers.go index b8232ef..eb4c048 100644 --- a/controller/internal/web/storage_handlers.go +++ b/controller/internal/web/storage_handlers.go @@ -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, }) } diff --git a/controller/internal/web/templates/storage_init.html b/controller/internal/web/templates/storage_init.html index 75eb9f1..dc3f627 100644 --- a/controller/internal/web/templates/storage_init.html +++ b/controller/internal/web/templates/storage_init.html @@ -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 = '
Nem található inicializálható meghajtó.
'; + if (!hasAvail && !hasFP) { + availEl.innerHTML = '
Nem található inicializálható meghajtó vagy partíció.
'; } else { - var html = '

Talált meghajtók (' + data.available.length + '):

'; - 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 += '
' + - '
' + - '○ ' + disk.Path + ' — ' + (disk.Size || '?') + '' + - (disk.Model ? '' + disk.Model + '' : '') + - (partInfo ? '' + partInfo + '' : 'Nincs partíció') + - '
'; - }); + var html = ''; + if (hasAvail) { + html += '

Talált meghajtók (' + data.available.length + '):

'; + 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 += '
' + + '
' + + '○ ' + disk.Path + ' — ' + (disk.Size || '?') + '' + + (disk.Model ? '' + disk.Model + '' : '') + + (partInfo ? '' + partInfo + '' : 'Nincs partíció') + + '
'; + }); + } + + if (hasFP) { + html += '

Formázható partíciók a rendszermeghajtón (' + + data.formatable_partitions.length + '):

'; + html += '
' + + '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.
'; + data.formatable_partitions.forEach(function(fp) { + var parentInfo = fp.ParentDiskPath + ' (' + fp.ParentDiskSize + ')'; + if (fp.ParentDiskModel) parentInfo += ' — ' + fp.ParentDiskModel; + html += '
' + + '
' + + '○ ' + fp.Path + ' — ' + (fp.Size || '?') + '' + + 'Rendszermeghajtó partíciója: ' + parentInfo + '' + + 'Nincs fájlrendszer — formázásra kész' + + '
'; + }); + } + 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 = '⚠️ FIGYELEM: A meghajtó ÖSSZES adata törlődik!
' + + 'Ez a művelet NEM vonható vissza.'; + } else { + warningEl.innerHTML = '⚠️ FIGYELEM: A partíció formázva lesz, a rajta lévő adatok törlődnek!
' + + 'A rendszermeghajtó többi partíciója NEM é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'});