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>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
//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
|
||||
}
|
||||
Reference in New Issue
Block a user