Files
admin 6713df2186 v0.15.5: Disaster recovery — Hub-based infra backup, auto-mount, restore UI
Complete DR implementation (TASK2.md Phases 1-4):
- Hub infra-backup push/pull endpoints (controller.yaml, disk layout, stacks)
- Fresh-deployment detection pulls config from Hub, auto-mounts drives by UUID
- Full-page restore UI with drive status, app table, sequential restore
- docker-setup.sh shows DR instructions when customer_id is configured

New files: disk_layout.go, restore_scan.go, restore_app_linux.go,
restore_drives_linux.go, infra_backup.go, infra_pull.go,
handler_restore.go, restore.html

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:16:46 +01:00

136 lines
3.3 KiB
Go

//go:build linux
package report
import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
)
// collectDiskLayout reads /host-fstab and correlates with blkid/lsblk to build
// the disk mount topology. Only includes data partitions (not root, boot, or swap).
func collectDiskLayout(systemDataPath string) backup.DiskLayout {
layout := backup.DiskLayout{}
fstabPath := "/host-fstab"
if _, err := os.Stat(fstabPath); err != nil {
fstabPath = "/etc/fstab"
}
data, err := os.ReadFile(fstabPath)
if err != nil {
return layout
}
// Parse fstab into UUID-based entries and bind mount entries
type fstabEntry struct {
source string
mountPoint string
fsType string
options string
}
var uuidEntries []fstabEntry
var bindEntries []fstabEntry
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) < 4 {
continue
}
source := fields[0]
mountPoint := fields[1]
fsType := fields[2]
options := fields[3]
// Skip system mounts and swap
if systemMounts[mountPoint] || fsType == "swap" {
continue
}
if strings.HasPrefix(source, "UUID=") {
uuidEntries = append(uuidEntries, fstabEntry{
source: strings.TrimPrefix(source, "UUID="),
mountPoint: mountPoint,
fsType: fsType,
options: options,
})
} else if fsType == "none" && strings.Contains(options, "bind") {
bindEntries = append(bindEntries, fstabEntry{
source: source,
mountPoint: mountPoint,
options: options,
})
}
}
// Process UUID-based entries
for _, e := range uuidEntries {
dm := backup.DiskMount{
UUID: e.source,
MountPoint: e.mountPoint,
FSType: e.fsType,
FstabOptions: e.options,
}
// Get label via blkid
if out, err := exec.Command("blkid", "-o", "value", "-s", "LABEL", "-U", e.source).Output(); err == nil {
dm.Label = strings.TrimSpace(string(out))
}
// Get size via lsblk (resolve UUID to device first)
if devPath, err := exec.Command("blkid", "-U", e.source).Output(); err == nil {
dev := strings.TrimSpace(string(devPath))
if dev != "" {
if out, err := exec.Command("lsblk", "-b", "-n", "-o", "SIZE", dev).Output(); err == nil {
if sz, err := strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64); err == nil {
dm.SizeBytes = sz
}
}
}
}
// Determine role
if e.mountPoint == systemDataPath {
dm.Role = "system_data"
} else {
dm.Role = "hdd_storage"
}
// Check for a corresponding bind mount
for _, bind := range bindEntries {
if strings.HasPrefix(bind.source, e.mountPoint+"/") {
subdir := strings.TrimPrefix(bind.source, e.mountPoint+"/")
dm.BindSubdir = subdir
dm.RawMount = e.mountPoint
dm.MountPoint = bind.mountPoint // the final user-facing mount point
break
}
}
// Get label from mount point basename as fallback
if dm.Label == "" {
if dm.RawMount != "" {
dm.Label = filepath.Base(dm.RawMount)
} else {
dm.Label = filepath.Base(dm.MountPoint)
}
}
layout.Mounts = append(layout.Mounts, dm)
}
return layout
}