95c821deb2
Add detailed [DEBUG] logging to every controller module when logging.level is set to "debug". Each module with stateful debug uses SetDebug(bool) wired from main.go. Covers stacks, backup, cloudflare, integrations, system, monitor, settings, scheduler, web handlers, storage, metrics, API, selfupdate, and assets. Also includes the app export/import (.fab bundles) feature from v0.32.0 and its debug page integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
348 lines
9.3 KiB
Go
348 lines
9.3 KiB
Go
//go:build linux
|
|
|
|
package storage
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
|
)
|
|
|
|
// lsblkOutput matches the top-level JSON from lsblk -J.
|
|
type lsblkOutput struct {
|
|
BlockDevices []lsblkDevice `json:"blockdevices"`
|
|
}
|
|
|
|
// lsblkDevice is the raw JSON structure from lsblk.
|
|
type lsblkDevice struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
Size interface{} `json:"size"` // may be float64 or string
|
|
Type string `json:"type"`
|
|
FSType *string `json:"fstype"`
|
|
MountPoint *string `json:"mountpoint"`
|
|
Model *string `json:"model"`
|
|
RM interface{} `json:"rm"` // removable: bool or "0"/"1"
|
|
Children []lsblkDevice `json:"children"`
|
|
}
|
|
|
|
func (d *lsblkDevice) sizeBytes() int64 {
|
|
switch v := d.Size.(type) {
|
|
case float64:
|
|
return int64(v)
|
|
case string:
|
|
// M3: lsblk can return size as a string on some kernel versions.
|
|
n, _ := strconv.ParseUint(v, 10, 64)
|
|
return int64(n)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (d *lsblkDevice) sizeHuman() string {
|
|
bytes := d.sizeBytes()
|
|
const (
|
|
GB = 1024 * 1024 * 1024
|
|
TB = GB * 1024
|
|
)
|
|
switch {
|
|
case bytes >= TB:
|
|
return fmt.Sprintf("%.1f TB", float64(bytes)/float64(TB))
|
|
case bytes >= GB:
|
|
return fmt.Sprintf("%.1f GB", float64(bytes)/float64(GB))
|
|
default:
|
|
return fmt.Sprintf("%d MB", bytes/(1024*1024))
|
|
}
|
|
}
|
|
|
|
func (d *lsblkDevice) isRemovable() bool {
|
|
switch v := d.RM.(type) {
|
|
case bool:
|
|
return v
|
|
case float64:
|
|
return v != 0
|
|
case string:
|
|
return v == "1" || strings.EqualFold(v, "true")
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (d *lsblkDevice) fsType() string {
|
|
if d.FSType != nil {
|
|
return *d.FSType
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (d *lsblkDevice) mountPoint() string {
|
|
if d.MountPoint != nil {
|
|
return *d.MountPoint
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (d *lsblkDevice) model() string {
|
|
if d.Model != nil {
|
|
return strings.TrimSpace(*d.Model)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// getSystemDiskNames returns the set of parent disk names (e.g., "sda")
|
|
// that contain system partitions (/, /boot, /boot/efi, swap).
|
|
// It reads the host's fstab (mounted at /host-fstab in the container)
|
|
// and resolves UUIDs to device paths via blkid.
|
|
func getSystemDiskNames() map[string]bool {
|
|
systemDisks := map[string]bool{}
|
|
|
|
// Step 1: Find and parse fstab
|
|
fstabPath := "/host-fstab"
|
|
if _, err := os.Stat(fstabPath); err != nil {
|
|
fstabPath = "/etc/fstab"
|
|
}
|
|
|
|
data, err := os.ReadFile(fstabPath)
|
|
if err != nil {
|
|
return systemDisks
|
|
}
|
|
|
|
systemMounts := map[string]bool{"/": true, "/boot": true, "/boot/efi": true}
|
|
|
|
var systemUUIDs []string
|
|
var systemDevices []string
|
|
|
|
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=") {
|
|
systemUUIDs = append(systemUUIDs, strings.TrimPrefix(source, "UUID="))
|
|
} else if strings.HasPrefix(source, "/dev/") {
|
|
systemDevices = append(systemDevices, source)
|
|
}
|
|
}
|
|
|
|
// Step 2: Resolve UUIDs to device paths via blkid
|
|
for _, uuid := range systemUUIDs {
|
|
out, err := exec.Command("blkid", "-U", uuid).Output()
|
|
if err == nil {
|
|
devPath := strings.TrimSpace(string(out))
|
|
if devPath != "" {
|
|
systemDevices = append(systemDevices, devPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 3: Extract parent disk names from device paths
|
|
for _, devPath := range systemDevices {
|
|
diskName := partitionToParentDisk(devPath)
|
|
if diskName != "" {
|
|
systemDisks[diskName] = true
|
|
}
|
|
}
|
|
|
|
return systemDisks
|
|
}
|
|
|
|
// 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 {
|
|
name := filepath.Base(devPath)
|
|
|
|
// H10: Handle mmcblk0p1 and nvme0n1p1 patterns where 'p' separates disk# from partition#.
|
|
// The prefix before 'p' must end with a digit (e.g., mmcblk0, nvme0n1) to be a disk number.
|
|
if idx := strings.LastIndex(name, "p"); idx > 0 {
|
|
prefix := name[:idx]
|
|
suffix := name[idx+1:]
|
|
if len(suffix) > 0 && suffix[0] >= '0' && suffix[0] <= '9' &&
|
|
len(prefix) > 0 && prefix[len(prefix)-1] >= '0' && prefix[len(prefix)-1] <= '9' {
|
|
// Verify suffix is all digits (partition number, not part of device name)
|
|
allDigits := true
|
|
for _, c := range suffix {
|
|
if c < '0' || c > '9' {
|
|
allDigits = false
|
|
break
|
|
}
|
|
}
|
|
if allDigits {
|
|
return prefix // e.g., mmcblk0, nvme0n1
|
|
}
|
|
}
|
|
}
|
|
|
|
// Standard: sda2 → sda, sdb1 → sdb
|
|
return strings.TrimRight(name, "0123456789")
|
|
}
|
|
|
|
// enrichWithBlkid fills in missing FSType, UUID, and Label on partitions using blkid.
|
|
// Probes each partition individually via /host-dev (Docker overrides /dev with its own
|
|
// tmpfs, so the host block devices are accessible at /host-dev instead).
|
|
func enrichWithBlkid(disks []BlockDevice, logger *log.Logger, debug bool) {
|
|
dbg := func(format string, args ...interface{}) {
|
|
if debug && logger != nil {
|
|
logger.Printf("[DEBUG] [storage] enrichWithBlkid: "+format, args...)
|
|
}
|
|
}
|
|
|
|
for i := range disks {
|
|
for j := range disks[i].Partitions {
|
|
p := &disks[i].Partitions[j]
|
|
hostPath := HostDevicePath(p.Path) // "/dev/sdb1" → "/host-dev/sdb1"
|
|
|
|
if p.FSType == "" {
|
|
if out, err := exec.Command("blkid", "-o", "value", "-s", "TYPE", hostPath).Output(); err == nil {
|
|
p.FSType = strings.TrimSpace(string(out))
|
|
dbg("blkid TYPE %s → %q", hostPath, p.FSType)
|
|
} else {
|
|
dbg("blkid TYPE %s failed: %v", hostPath, err)
|
|
}
|
|
}
|
|
if p.UUID == "" {
|
|
if out, err := exec.Command("blkid", "-o", "value", "-s", "UUID", hostPath).Output(); err == nil {
|
|
p.UUID = strings.TrimSpace(string(out))
|
|
dbg("blkid UUID %s → %q", hostPath, p.UUID)
|
|
} else {
|
|
dbg("blkid UUID %s failed: %v", hostPath, err)
|
|
}
|
|
}
|
|
if p.Label == "" {
|
|
if out, err := exec.Command("blkid", "-o", "value", "-s", "LABEL", hostPath).Output(); err == nil {
|
|
p.Label = strings.TrimSpace(string(out))
|
|
dbg("blkid LABEL %s → %q", hostPath, p.Label)
|
|
} else {
|
|
dbg("blkid LABEL %s failed: %v", hostPath, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ScanDisks detects all block devices and classifies them into
|
|
// available (not mounted, not system) and system/mounted disks.
|
|
func ScanDisks(logger *log.Logger, debug bool) (*ScanResult, error) {
|
|
dbg := func(format string, args ...interface{}) {
|
|
if debug && logger != nil {
|
|
logger.Printf("[DEBUG] [storage] ScanDisks: "+format, args...)
|
|
}
|
|
}
|
|
|
|
dbg("starting disk scan")
|
|
scanStart := time.Now()
|
|
|
|
out, err := exec.Command(
|
|
"lsblk", "-J", "-b",
|
|
"-o", "NAME,PATH,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL,RM",
|
|
).Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lsblk failed: %w", err)
|
|
}
|
|
|
|
dbg("raw lsblk JSON: %s", util.TruncateStr(string(out), 500))
|
|
|
|
var parsed lsblkOutput
|
|
if err := json.Unmarshal(out, &parsed); err != nil {
|
|
return nil, fmt.Errorf("lsblk JSON parse failed: %w", err)
|
|
}
|
|
|
|
dbg("lsblk returned %d block devices", len(parsed.BlockDevices))
|
|
|
|
// Get system disk names from host fstab (works correctly inside container)
|
|
systemDiskNames := getSystemDiskNames()
|
|
|
|
result := &ScanResult{}
|
|
|
|
for _, dev := range parsed.BlockDevices {
|
|
if dev.Type != "disk" {
|
|
continue
|
|
}
|
|
|
|
bd := BlockDevice{
|
|
Name: dev.Name,
|
|
Path: dev.Path,
|
|
Size: dev.sizeHuman(),
|
|
SizeBytes: dev.sizeBytes(),
|
|
Model: dev.model(),
|
|
Type: dev.Type,
|
|
Removable: dev.isRemovable(),
|
|
}
|
|
if bd.Path == "" {
|
|
bd.Path = "/dev/" + bd.Name
|
|
}
|
|
|
|
anyMounted := false
|
|
for _, child := range dev.Children {
|
|
if child.Type != "part" && child.Type != "lvm" && child.Type != "crypt" {
|
|
continue
|
|
}
|
|
part := Partition{
|
|
Name: child.Name,
|
|
Path: child.Path,
|
|
Size: child.sizeHuman(),
|
|
SizeBytes: child.sizeBytes(),
|
|
FSType: child.fsType(),
|
|
MountPoint: child.mountPoint(),
|
|
}
|
|
if part.Path == "" {
|
|
part.Path = "/dev/" + part.Name
|
|
}
|
|
bd.Partitions = append(bd.Partitions, part)
|
|
if part.MountPoint != "" {
|
|
anyMounted = true
|
|
}
|
|
}
|
|
|
|
// Also check if the disk itself is directly mounted (no partition table)
|
|
if dev.mountPoint() != "" {
|
|
anyMounted = true
|
|
}
|
|
|
|
isSystem := systemDiskNames[dev.Name]
|
|
bd.Mounted = anyMounted || isSystem
|
|
|
|
classification := "available"
|
|
if isSystem || anyMounted {
|
|
classification = "system"
|
|
result.SystemDisks = append(result.SystemDisks, bd)
|
|
} else {
|
|
result.AvailableDisks = append(result.AvailableDisks, bd)
|
|
}
|
|
|
|
dbg("disk %s: model=%q size=%s partitions=%d removable=%v classification=%s",
|
|
bd.Name, bd.Model, bd.Size, len(bd.Partitions), bd.Removable, classification)
|
|
|
|
for _, p := range bd.Partitions {
|
|
dbg(" partition %s: fstype=%q mountpoint=%q size=%s", p.Name, p.FSType, p.MountPoint, p.Size)
|
|
}
|
|
}
|
|
|
|
dbg("classification result: %d available, %d system", len(result.AvailableDisks), len(result.SystemDisks))
|
|
|
|
// Enrich FSType, UUID, Label from blkid (lsblk can't probe fstype in container)
|
|
enrichWithBlkid(result.AvailableDisks, logger, debug)
|
|
enrichWithBlkid(result.SystemDisks, logger, debug)
|
|
|
|
dbg("disk scan completed in %s", time.Since(scanStart).Round(time.Millisecond))
|
|
|
|
return result, nil
|
|
}
|