feat: infra backup retention + version picker

Hub: GFS retention (7d/4w/3m, ~14 versions) in new infra_backup_versions
table. Recovery endpoint supports ?version=ID. New /versions API endpoint.
Dashboard shows backup history.

Controller: local drive backups rotated into history/ (last 5 versions).
Setup wizard shows version picker for Hub restores when multiple versions
exist. Scan results enriched with app names, disk count, history badge.
Local restore supports historical versions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 14:47:40 +01:00
parent 8f49bcc4cc
commit c0cdd95e56
9 changed files with 540 additions and 80 deletions
+8 -1
View File
@@ -1,15 +1,22 @@
## Changelog ## Changelog
### v0.31.7 — Hub mode: setup wizard with infra backup restore (2026-02-26) ### v0.31.7 — Infra backup retention + version picker (2026-02-26)
#### Changed #### Changed
- **docker-setup.sh hub mode**: `--hub-customer` now generates a minimal `controller.yaml` (no `customer.id`) instead of installing the full hub config — this triggers the setup wizard on first run, giving the user a choice to restore from an infra backup or start fresh - **docker-setup.sh hub mode**: `--hub-customer` now generates a minimal `controller.yaml` (no `customer.id`) instead of installing the full hub config — this triggers the setup wizard on first run, giving the user a choice to restore from an infra backup or start fresh
- **docker-setup.sh**: Hub credentials are passed to the controller via `FELHOM_SETUP_CUSTOMER_ID` and `FELHOM_SETUP_PASSWORD` environment variables so the setup wizard auto-fills them - **docker-setup.sh**: Hub credentials are passed to the controller via `FELHOM_SETUP_CUSTOMER_ID` and `FELHOM_SETUP_PASSWORD` environment variables so the setup wizard auto-fills them
- **Local infra backup**: `WriteLocalInfraBackup()` now rotates previous backup into `history/` subdirectory before writing new files (keeps last 5 versions per drive)
- **Setup wizard scan results**: Table now shows app names/count, disk count, and "korábbi" badge for historical versions
#### Added #### Added
- **Setup wizard hub pre-seeding**: When deployed with `--hub-customer`, the wizard auto-detects pre-seeded credentials and auto-processes Hub API calls (no manual form entry needed) - **Setup wizard hub pre-seeding**: When deployed with `--hub-customer`, the wizard auto-detects pre-seeded credentials and auto-processes Hub API calls (no manual form entry needed)
- **Hub mode welcome page**: Shows three options instead of two — "Visszaállítás a Hub-ról" (auto-connects to Hub), "Helyi mentés keresése" (local drive scan), "Friss telepítés" (fresh config download) - **Hub mode welcome page**: Shows three options instead of two — "Visszaállítás a Hub-ról" (auto-connects to Hub), "Helyi mentés keresése" (local drive scan), "Friss telepítés" (fresh config download)
- **Auto-process fallback**: If Hub auto-connect fails, the wizard clears the pre-seeded password and falls back to the manual form with the error displayed - **Auto-process fallback**: If Hub auto-connect fails, the wizard clears the pre-seeded password and falls back to the manual form with the error displayed
- **Hub backup version picker**: When multiple backup versions exist on the Hub, the setup wizard shows a version picker page (date, controller version, app names, disk count) — user selects which version to restore
- **Local backup history restore**: Setup wizard can restore from historical versions found in `history/` subdirectory on local drives
- **`ReadLocalInfraHistory()`**: Scans `history/` directory for all retained backup versions with rich metadata (stack names, disk count, integrity status)
- **`ReadLocalInfraBackupFromHistory()`**: Reads a specific historical version by timestamp prefix
- **`PullRecoveryVersion()`**: Fetches a specific backup version from the Hub recovery endpoint via `?version=ID` parameter
#### Fixed #### Fixed
- **Bind mount write**: `atomicWriteFile()` now falls back to direct write when rename fails (fixes "device or resource busy" on Docker bind-mounted `controller.yaml`) - **Bind mount write**: `atomicWriteFile()` now falls back to direct write when rename fails (fixes "device or resource busy" on Docker bind-mounted `controller.yaml`)
+10 -7
View File
@@ -989,19 +989,22 @@ On error, the wizard falls back to the manual form with the error displayed.
|------|---------| |------|---------|
| `setup/setup.go` | `NeedsSetup()` detection, `SetupState` persistence to `setup-state.json` | | `setup/setup.go` | `NeedsSetup()` detection, `SetupState` persistence to `setup-state.json` |
| `setup/handlers.go` | HTTP handlers for each wizard step (welcome, scan, hub-restore, fresh, manual) | | `setup/handlers.go` | HTTP handlers for each wizard step (welcome, scan, hub-restore, fresh, manual) |
| `setup/scanner.go` | Scans all block devices for `.felhom-infra-backup/` directories via `lsblk` + temp mounts | | `setup/scanner.go` | Scans all block devices for `.felhom-infra-backup/` directories (current + `history/`) via `lsblk` + temp mounts; returns rich info (app names, disk count) |
| `setup/hub.go` | Hub recovery pull (`GET /api/v1/recovery/{id}`) and config download | | `setup/hub.go` | Hub recovery pull (`GET /api/v1/recovery/{id}`) and config download |
| `setup/csrf.go` | Lightweight CSRF protection (cookie + hidden field, `SameSite=Strict`) | | `setup/csrf.go` | Lightweight CSRF protection (cookie + hidden field, `SameSite=Strict`) |
| `setup/network.go` | Detects local IPs for LAN access URL display | | `setup/network.go` | Detects local IPs for LAN access URL display |
| `setup/templates/` | 7 embedded HTML templates (Hungarian, dark theme matching main UI) | | `setup/templates/` | 8 embedded HTML templates (Hungarian, dark theme matching main UI) — includes `setup_hub_versions.html` for Hub backup version picker |
#### Local Infra Backup (`internal/backup/local_infra.go`) #### Local Infra Backup (`internal/backup/local_infra.go`)
The controller writes infrastructure snapshots to **every connected drive** after each backup cycle and on startup. Location: `<drive>/.felhom-infra-backup/`. Files: The controller writes infrastructure snapshots to **every connected drive** after each backup cycle and on startup. Location: `<drive>/.felhom-infra-backup/`. Files:
- `backup.json` — full infra backup (config, settings, disk layout, passwords, stacks) - `backup.json` — full infra backup (config, settings, disk layout, passwords, stacks)
- `metadata.json` — schema version, timestamp, customer ID, controller version, SHA256 checksum - `metadata.json` — schema version, timestamp, customer ID, controller version, SHA256 checksum
- `history/` — previous backup versions (last 5), rotated automatically before each write
- `{timestamp}-backup.json` + `{timestamp}-metadata.json` pairs (timestamp format: `20060102T150405Z`)
- Oldest entries pruned when count exceeds 5
During setup wizard drive scan, these backups are discovered, integrity-verified, and offered for one-click restore. During setup wizard drive scan, both current and historical backups are discovered, integrity-verified, and offered for one-click restore. The scan results table shows app names/count, disk count, and a "korábbi" badge for historical versions.
#### Recovery Info (`internal/recovery/info.go`) #### Recovery Info (`internal/recovery/info.go`)
@@ -1019,9 +1022,9 @@ When a system drive fails and is replaced, the recovery flow uses the setup wiza
3. User opens wizard at http://<LAN-IP>:8081 3. User opens wizard at http://<LAN-IP>:8081
4. Hub mode: welcome page shows Hub restore / local scan / fresh install 4. Hub mode: welcome page shows Hub restore / local scan / fresh install
Non-hub mode: welcome page shows restore / fresh install Non-hub mode: welcome page shows restore / fresh install
5. Hub restore: auto-connects to Hub, shows infra backup details 5. Hub restore: auto-connects to Hub, shows version picker if multiple versions
Local restore: scans all drives for .felhom-infra-backup/ directories Local restore: scans all drives for .felhom-infra-backup/ directories (current + history/)
6. One-click restore: config, settings, passwords, disk layout 6. User selects backup version → restore: config, settings, passwords, disk layout
7. Controller restarts into normal mode with full config 7. Controller restarts into normal mode with full config
8. Controller auto-mounts surviving drives by UUID from disk layout 8. Controller auto-mounts surviving drives by UUID from disk layout
9. Dashboard shows "Visszaállítás" (Restore) page for app-level recovery 9. Dashboard shows "Visszaállítás" (Restore) page for app-level recovery
@@ -1030,7 +1033,7 @@ When a system drive fails and is replaced, the recovery flow uses the setup wiza
**Backup sources (priority order):** **Backup sources (priority order):**
1. **Local infra backup** (`.felhom-infra-backup/` on surviving drives) — fastest, no network needed 1. **Local infra backup** (`.felhom-infra-backup/` on surviving drives) — fastest, no network needed
2. **Hub recovery endpoint** (`GET /api/v1/recovery/{id}`) — requires retrieval password 2. **Hub recovery endpoint** (`GET /api/v1/recovery/{id}`) — requires retrieval password, supports `?version=ID` for specific versions; Hub retains ~14 versions via GFS pruning (7 daily / 4 weekly / 3 monthly)
3. **Manual config** (wizard form) — enter all details manually as last resort 3. **Manual config** (wizard form) — enter all details manually as last resort
**Hub verification:** After setup, the controller periodically verifies customer standing via the Hub report push response (`customer_blocked` field). If blocked or Hub unreachable for >7 days, the controller enters limited mode (no new deployments). **Hub verification:** After setup, the controller periodically verifies customer standing via the Hub report push response (`customer_blocked` field). If blocked or Hub unreachable for >7 days, the controller enters limited mode (no new deployments).
+224 -5
View File
@@ -8,11 +8,17 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings"
"time"
) )
// MaxSchemaVersion is the highest infra backup schema version this controller can read. // MaxSchemaVersion is the highest infra backup schema version this controller can read.
const MaxSchemaVersion = 1 const MaxSchemaVersion = 1
// maxLocalHistory is the number of previous backup versions to keep per drive.
const maxLocalHistory = 5
// InfraMetadata is the lightweight metadata file written alongside backup.json. // InfraMetadata is the lightweight metadata file written alongside backup.json.
type InfraMetadata struct { type InfraMetadata struct {
SchemaVersion int `json:"schema_version"` SchemaVersion int `json:"schema_version"`
@@ -58,7 +64,7 @@ func WriteLocalInfraBackup(backupJSON []byte, customerID, controllerVersion, tim
if debug { if debug {
logger.Printf("[DEBUG] WriteLocalInfraBackup: writing to drive=%s, dir=%s", drive, dir) logger.Printf("[DEBUG] WriteLocalInfraBackup: writing to drive=%s, dir=%s", drive, dir)
} }
if err := writeInfraToDir(dir, backupJSON, metaJSON); err != nil { if err := writeInfraToDir(dir, backupJSON, metaJSON, logger); err != nil {
logger.Printf("[WARN] Local infra backup: failed to write to %s: %v", drive, err) logger.Printf("[WARN] Local infra backup: failed to write to %s: %v", drive, err)
continue continue
} }
@@ -71,12 +77,15 @@ func WriteLocalInfraBackup(backupJSON []byte, customerID, controllerVersion, tim
logger.Printf("[INFO] Local infra backup written to %d/%d drive(s)", written, len(drives)) logger.Printf("[INFO] Local infra backup written to %d/%d drive(s)", written, len(drives))
} }
// writeInfraToDir writes backup.json and metadata.json atomically to the given directory. // writeInfraToDir rotates the current backup into history/ then writes new backup.json and metadata.json.
func writeInfraToDir(dir string, backupData, metaData []byte) error { func writeInfraToDir(dir string, backupData, metaData []byte, logger *log.Logger) error {
if err := os.MkdirAll(dir, 0700); err != nil { if err := os.MkdirAll(dir, 0700); err != nil {
return fmt.Errorf("creating dir: %w", err) return fmt.Errorf("creating dir: %w", err)
} }
// Rotate current backup to history (best-effort)
rotateToHistory(dir, logger)
// Write backup.json atomically // Write backup.json atomically
backupPath := filepath.Join(dir, "backup.json") backupPath := filepath.Join(dir, "backup.json")
if err := atomicWrite(backupPath, backupData, 0600); err != nil { if err := atomicWrite(backupPath, backupData, 0600); err != nil {
@@ -92,6 +101,106 @@ func writeInfraToDir(dir string, backupData, metaData []byte) error {
return nil return nil
} }
// rotateToHistory moves the current backup.json + metadata.json into history/{timestamp}-*.
func rotateToHistory(dir string, logger *log.Logger) {
metaPath := filepath.Join(dir, "metadata.json")
backupPath := filepath.Join(dir, "backup.json")
// Read current metadata to get timestamp
metaData, err := os.ReadFile(metaPath)
if err != nil {
return // no existing backup to rotate
}
var meta InfraMetadata
if err := json.Unmarshal(metaData, &meta); err != nil {
return
}
// Parse timestamp, fall back to file mtime
ts := sanitizeTimestamp(meta.Timestamp)
if ts == "" {
if fi, err := os.Stat(metaPath); err == nil {
ts = fi.ModTime().UTC().Format("20060102T150405Z")
} else {
ts = time.Now().UTC().Format("20060102T150405Z")
}
}
histDir := filepath.Join(dir, "history")
if err := os.MkdirAll(histDir, 0700); err != nil {
if logger != nil {
logger.Printf("[WARN] Local infra history: cannot create history dir: %v", err)
}
return
}
// Move files
histBackup := filepath.Join(histDir, ts+"-backup.json")
histMeta := filepath.Join(histDir, ts+"-metadata.json")
// Copy rather than rename to avoid cross-device issues
if data, err := os.ReadFile(backupPath); err == nil {
os.WriteFile(histBackup, data, 0600) //nolint:errcheck
}
os.WriteFile(histMeta, metaData, 0600) //nolint:errcheck
// Prune old history entries
pruneLocalHistory(histDir, maxLocalHistory, logger)
}
// pruneLocalHistory keeps at most maxKeep metadata+backup pairs, deleting the oldest.
func pruneLocalHistory(histDir string, maxKeep int, logger *log.Logger) {
entries, err := os.ReadDir(histDir)
if err != nil {
return
}
// Collect unique timestamps (each has -backup.json and -metadata.json)
timestamps := make(map[string]bool)
for _, e := range entries {
name := e.Name()
if strings.HasSuffix(name, "-metadata.json") {
ts := strings.TrimSuffix(name, "-metadata.json")
timestamps[ts] = true
}
}
if len(timestamps) <= maxKeep {
return
}
// Sort timestamps ascending (oldest first)
sorted := make([]string, 0, len(timestamps))
for ts := range timestamps {
sorted = append(sorted, ts)
}
sort.Strings(sorted)
// Delete oldest entries beyond limit
toDelete := len(sorted) - maxKeep
for i := 0; i < toDelete; i++ {
ts := sorted[i]
os.Remove(filepath.Join(histDir, ts+"-backup.json"))
os.Remove(filepath.Join(histDir, ts+"-metadata.json"))
if logger != nil {
logger.Printf("[DEBUG] Local infra history: pruned old version %s", ts)
}
}
}
// sanitizeTimestamp converts an RFC3339 timestamp to a filename-safe format.
func sanitizeTimestamp(ts string) string {
t, err := time.Parse(time.RFC3339, ts)
if err != nil {
t, err = time.Parse(time.RFC3339Nano, ts)
if err != nil {
return ""
}
}
return t.UTC().Format("20060102T150405Z")
}
// atomicWrite writes data to a .tmp file then renames to the target path. // atomicWrite writes data to a .tmp file then renames to the target path.
func atomicWrite(path string, data []byte, perm os.FileMode) error { func atomicWrite(path string, data []byte, perm os.FileMode) error {
tmp := path + ".tmp" tmp := path + ".tmp"
@@ -110,9 +219,120 @@ func atomicWrite(path string, data []byte, perm os.FileMode) error {
// Returns the raw backup JSON, metadata, and any error. // Returns the raw backup JSON, metadata, and any error.
func ReadLocalInfraBackup(mountPath string) ([]byte, *InfraMetadata, error) { func ReadLocalInfraBackup(mountPath string) ([]byte, *InfraMetadata, error) {
dir := InfraBackupDir(mountPath) dir := InfraBackupDir(mountPath)
return readInfraBackupFromDir(dir)
}
// Read metadata // ReadLocalInfraBackupFromHistory reads a specific historical version by its timestamp prefix.
func ReadLocalInfraBackupFromHistory(mountPath, historyPrefix string) ([]byte, *InfraMetadata, error) {
histDir := InfraBackupHistoryDir(mountPath)
metaPath := filepath.Join(histDir, historyPrefix+"-metadata.json")
backupPath := filepath.Join(histDir, historyPrefix+"-backup.json")
return readInfraBackupFromFiles(backupPath, metaPath)
}
// LocalBackupVersion holds summary info for a historical backup version found on a drive.
type LocalBackupVersion struct {
Timestamp string `json:"timestamp"`
CustomerID string `json:"customer_id"`
ControllerVersion string `json:"controller_version"`
IntegrityOK bool `json:"integrity_ok"`
Error string `json:"error,omitempty"`
StackCount int `json:"stack_count"`
StackNames []string `json:"stack_names,omitempty"`
DiskCount int `json:"disk_count"`
HistoryFile string `json:"history_file,omitempty"` // empty = current, timestamp prefix for history
}
// ReadLocalInfraHistory reads all historical backup versions from a mount point's history/ directory.
// Returns newest-first. Does NOT include the current backup (use ReadLocalInfraBackup for that).
func ReadLocalInfraHistory(mountPath string) []LocalBackupVersion {
histDir := InfraBackupHistoryDir(mountPath)
entries, err := os.ReadDir(histDir)
if err != nil {
return nil
}
// Collect unique timestamps
var timestamps []string
seen := make(map[string]bool)
for _, e := range entries {
name := e.Name()
if strings.HasSuffix(name, "-metadata.json") {
ts := strings.TrimSuffix(name, "-metadata.json")
if !seen[ts] {
seen[ts] = true
timestamps = append(timestamps, ts)
}
}
}
// Sort descending (newest first)
sort.Sort(sort.Reverse(sort.StringSlice(timestamps)))
var versions []LocalBackupVersion
for _, ts := range timestamps {
v := LocalBackupVersion{HistoryFile: ts}
backupPath := filepath.Join(histDir, ts+"-backup.json")
metaPath := filepath.Join(histDir, ts+"-metadata.json")
backupData, meta, err := readInfraBackupFromFiles(backupPath, metaPath)
if meta != nil {
v.Timestamp = meta.Timestamp
v.CustomerID = meta.CustomerID
v.ControllerVersion = meta.ControllerVersion
}
if err != nil {
v.IntegrityOK = false
v.Error = err.Error()
} else {
v.IntegrityOK = true
ParseBackupCounts(backupData, &v.StackCount, &v.StackNames, &v.DiskCount)
}
versions = append(versions, v)
}
return versions
}
// ParseBackupCounts extracts stack/disk counts from backup JSON (for display purposes).
func ParseBackupCounts(backupJSON []byte, stackCount *int, stackNames *[]string, diskCount *int) {
var parsed struct {
DeployedStacks []struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
} `json:"deployed_stacks"`
DiskLayout struct {
Mounts []json.RawMessage `json:"mounts"`
} `json:"disk_layout"`
}
if err := json.Unmarshal(backupJSON, &parsed); err != nil {
return
}
*stackCount = len(parsed.DeployedStacks)
*diskCount = len(parsed.DiskLayout.Mounts)
if stackNames != nil {
for _, s := range parsed.DeployedStacks {
name := s.DisplayName
if name == "" {
name = s.Name
}
*stackNames = append(*stackNames, name)
}
}
}
func readInfraBackupFromDir(dir string) ([]byte, *InfraMetadata, error) {
metaPath := filepath.Join(dir, "metadata.json") metaPath := filepath.Join(dir, "metadata.json")
backupPath := filepath.Join(dir, "backup.json")
return readInfraBackupFromFiles(backupPath, metaPath)
}
func readInfraBackupFromFiles(backupPath, metaPath string) ([]byte, *InfraMetadata, error) {
// Read metadata
metaData, err := os.ReadFile(metaPath) metaData, err := os.ReadFile(metaPath)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("reading metadata.json: %w", err) return nil, nil, fmt.Errorf("reading metadata.json: %w", err)
@@ -129,7 +349,6 @@ func ReadLocalInfraBackup(mountPath string) ([]byte, *InfraMetadata, error) {
} }
// Read backup data // Read backup data
backupPath := filepath.Join(dir, "backup.json")
backupData, err := os.ReadFile(backupPath) backupData, err := os.ReadFile(backupPath)
if err != nil { if err != nil {
return nil, &meta, fmt.Errorf("reading backup.json: %w", err) return nil, &meta, fmt.Errorf("reading backup.json: %w", err)
+5
View File
@@ -49,3 +49,8 @@ func AppDataDir(drivePath, stackName string) string {
func InfraBackupDir(mountPath string) string { func InfraBackupDir(mountPath string) string {
return filepath.Join(mountPath, ".felhom-infra-backup") return filepath.Join(mountPath, ".felhom-infra-backup")
} }
// InfraBackupHistoryDir returns the history subdirectory for versioned infra backups on a drive.
func InfraBackupHistoryDir(mountPath string) string {
return filepath.Join(mountPath, ".felhom-infra-backup", "history")
}
+56 -4
View File
@@ -18,12 +18,22 @@ var (
ErrHubError = errors.New("hub error") ErrHubError = errors.New("hub error")
) )
// BackupVersionSummary holds metadata about one backup version (from Hub).
type BackupVersionSummary struct {
ID int64 `json:"id"`
CreatedAt string `json:"created_at"`
StackCount int `json:"stack_count"`
DiskCount int `json:"disk_count"`
StackNames []string `json:"stack_names,omitempty"`
}
// RecoveryResponse is the combined config + infra backup from the Hub recovery endpoint. // RecoveryResponse is the combined config + infra backup from the Hub recovery endpoint.
type RecoveryResponse struct { type RecoveryResponse struct {
CustomerID string `json:"customer_id"` CustomerID string `json:"customer_id"`
ConfigYAML string `json:"config_yaml"` ConfigYAML string `json:"config_yaml"`
InfraBackup *InfraBackup `json:"infra_backup"` InfraBackup *InfraBackup `json:"infra_backup"`
HasInfraBackup bool `json:"has_infra_backup"` HasInfraBackup bool `json:"has_infra_backup"`
BackupVersions []BackupVersionSummary `json:"backup_versions,omitempty"`
} }
// PullRecovery fetches combined recovery data from the Hub (config + infra backup). // PullRecovery fetches combined recovery data from the Hub (config + infra backup).
@@ -69,6 +79,48 @@ func PullRecovery(hubURL, customerID, retrievalPassword string) (*RecoveryRespon
return &rr, nil return &rr, nil
} }
// PullRecoveryVersion fetches recovery data for a specific backup version ID.
func PullRecoveryVersion(hubURL, customerID, retrievalPassword string, versionID int64) (*RecoveryResponse, error) {
url := strings.TrimRight(hubURL, "/") + "/api/v1/recovery/" + customerID + fmt.Sprintf("?version=%d", versionID)
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrHubError, err)
}
req.Header.Set("X-Retrieval-Password", retrievalPassword)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrHubUnreachable, err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
// success
case http.StatusUnauthorized:
return nil, ErrAuthFailed
case http.StatusNotFound:
return nil, ErrNotFound
default:
return nil, fmt.Errorf("%w: HTTP %d", ErrHubError, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
return nil, fmt.Errorf("%w: reading response: %v", ErrHubError, err)
}
var rr RecoveryResponse
if err := json.Unmarshal(body, &rr); err != nil {
return nil, fmt.Errorf("%w: parsing response: %v", ErrHubError, err)
}
return &rr, nil
}
// PullConfig fetches a generated controller.yaml from the Hub config endpoint. // PullConfig fetches a generated controller.yaml from the Hub config endpoint.
// Auth: X-Retrieval-Password header. // Auth: X-Retrieval-Password header.
func PullConfig(hubURL, customerID, retrievalPassword string) (string, error) { func PullConfig(hubURL, customerID, retrievalPassword string) (string, error) {
+92 -29
View File
@@ -115,6 +115,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/setup/scan", s.handleScan) mux.HandleFunc("/setup/scan", s.handleScan)
mux.HandleFunc("/setup/scan/status", s.handleScanStatus) mux.HandleFunc("/setup/scan/status", s.handleScanStatus)
mux.HandleFunc("/setup/hub-restore", s.handleHubRestore) mux.HandleFunc("/setup/hub-restore", s.handleHubRestore)
mux.HandleFunc("/setup/hub-restore/select", s.handleHubVersionSelect)
mux.HandleFunc("/setup/restore", s.handleRestore) mux.HandleFunc("/setup/restore", s.handleRestore)
mux.HandleFunc("/setup/restore/status", s.handleRestoreStatus) mux.HandleFunc("/setup/restore/status", s.handleRestoreStatus)
mux.HandleFunc("/setup/fresh", s.handleFreshHub) mux.HandleFunc("/setup/fresh", s.handleFreshHub)
@@ -233,7 +234,8 @@ func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
switch source { switch source {
case "local": case "local":
drivePath := r.FormValue("drive_path") drivePath := r.FormValue("drive_path")
go s.executeLocalRestore(drivePath) historyFile := r.FormValue("history_file")
go s.executeLocalRestore(drivePath, historyFile)
case "hub": case "hub":
go s.executeHubRestore() go s.executeHubRestore()
default: default:
@@ -372,10 +374,31 @@ func (s *Server) autoProcessHubRestore(w http.ResponseWriter, r *http.Request, c
} }
if s.isDebug() { if s.isDebug() {
s.logger.Printf("[DEBUG] Setup: hub recovery received — hasInfra=%v, configLen=%d", recovery.HasInfraBackup, len(recovery.ConfigYAML)) s.logger.Printf("[DEBUG] Setup: hub recovery received — hasInfra=%v, configLen=%d, versions=%d", recovery.HasInfraBackup, len(recovery.ConfigYAML), len(recovery.BackupVersions))
} }
// Store recovery data in state for restore execution // If multiple versions available, show picker instead of auto-restoring
if len(recovery.BackupVersions) > 1 && recovery.HasInfraBackup {
s.logger.Printf("[INFO] Setup: %d backup versions available — showing version picker", len(recovery.BackupVersions))
// Store config for later use after version selection
s.state.SetFormField("hub_config_yaml", recovery.ConfigYAML)
s.state.Save()
csrf := ensureCSRFToken(w, r)
data := map[string]interface{}{
"CSRF": csrf,
"Versions": recovery.BackupVersions,
}
s.render(w, "setup_hub_versions", data)
return
}
// Single version or no versions — proceed directly
s.storeRecoveryAndRestore(w, r, recovery, customerID)
}
// storeRecoveryAndRestore stores recovery data in state and starts the restore goroutine.
func (s *Server) storeRecoveryAndRestore(w http.ResponseWriter, r *http.Request, recovery *report.RecoveryResponse, customerID string) {
s.state.SelectedBackup = &SelectedBackup{ s.state.SelectedBackup = &SelectedBackup{
Source: "hub", Source: "hub",
CustomerID: customerID, CustomerID: customerID,
@@ -389,9 +412,8 @@ func (s *Server) autoProcessHubRestore(w http.ResponseWriter, r *http.Request, c
s.state.SetStep("restore-exec") s.state.SetStep("restore-exec")
s.state.Save() s.state.Save()
s.logger.Printf("[INFO] Setup: hub recovery received (hasInfra=%v) — starting restore", recovery.HasInfraBackup) s.logger.Printf("[INFO] Setup: hub recovery stored (hasInfra=%v) — starting restore", recovery.HasInfraBackup)
// Start the restore goroutine, then render the progress page
go s.executeHubRestore() go s.executeHubRestore()
csrf := ensureCSRFToken(w, r) csrf := ensureCSRFToken(w, r)
@@ -476,34 +498,69 @@ func (s *Server) processHubRestore(w http.ResponseWriter, r *http.Request) {
} }
if s.isDebug() { if s.isDebug() {
s.logger.Printf("[DEBUG] Setup: hub recovery received — hasInfra=%v, configLen=%d", recovery.HasInfraBackup, len(recovery.ConfigYAML)) s.logger.Printf("[DEBUG] Setup: hub recovery received — hasInfra=%v, configLen=%d, versions=%d", recovery.HasInfraBackup, len(recovery.ConfigYAML), len(recovery.BackupVersions))
} }
// Store recovery data in state for restore execution
s.state.SelectedBackup = &SelectedBackup{
Source: "hub",
CustomerID: customerID,
}
s.state.SetFormField("retrieval_password", password) s.state.SetFormField("retrieval_password", password)
s.state.SetFormField("hub_config_yaml", recovery.ConfigYAML)
if recovery.HasInfraBackup && recovery.InfraBackup != nil { // If multiple versions available, show picker
ibJSON, _ := json.Marshal(recovery.InfraBackup) if len(recovery.BackupVersions) > 1 && recovery.HasInfraBackup {
s.state.SetFormField("hub_infra_backup", string(ibJSON)) s.logger.Printf("[INFO] Setup: %d backup versions available — showing version picker", len(recovery.BackupVersions))
s.state.SelectedBackup.Timestamp = recovery.InfraBackup.Timestamp s.state.SetFormField("hub_config_yaml", recovery.ConfigYAML)
s.state.Save()
csrf := ensureCSRFToken(w, r)
data := map[string]interface{}{
"CSRF": csrf,
"Versions": recovery.BackupVersions,
}
s.render(w, "setup_hub_versions", data)
return
} }
s.state.SetStep("restore-exec")
s.state.Save()
s.logger.Printf("[INFO] Setup: hub recovery received (hasInfra=%v) — starting restore", recovery.HasInfraBackup) s.storeRecoveryAndRestore(w, r, recovery, customerID)
}
// Start the restore goroutine, then render the progress page // handleHubVersionSelect processes the user's version selection from the Hub version picker.
go s.executeHubRestore() func (s *Server) handleHubVersionSelect(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
csrf := ensureCSRFToken(w, r) http.Redirect(w, r, "/setup/hub-restore", http.StatusFound)
data := map[string]interface{}{ return
"CSRF": csrf,
} }
s.render(w, "setup_restore_exec", data) if !validateCSRF(r) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
versionStr := r.FormValue("version_id")
customerID := s.state.GetFormField("customer_id")
password := s.state.GetFormField("retrieval_password")
hubURL := DefaultHubURL
if customerID == "" || password == "" {
http.Redirect(w, r, "/setup/hub-restore", http.StatusFound)
return
}
var versionID int64
fmt.Sscanf(versionStr, "%d", &versionID)
s.logger.Printf("[INFO] Setup: user selected backup version %d for %s", versionID, customerID)
// Fetch the specific version
recovery, err := report.PullRecoveryVersion(hubURL, customerID, password, versionID)
if err != nil {
s.logger.Printf("[ERROR] Setup: failed to fetch version %d: %v", versionID, err)
csrf := ensureCSRFToken(w, r)
data := map[string]interface{}{
"CSRF": csrf,
"Error": fmt.Sprintf("Hiba a verzió letöltésekor: %v", err),
}
s.render(w, "setup_hub_versions", data)
return
}
s.storeRecoveryAndRestore(w, r, recovery, customerID)
} }
func (s *Server) processFreshHub(w http.ResponseWriter, r *http.Request) { func (s *Server) processFreshHub(w http.ResponseWriter, r *http.Request) {
@@ -618,7 +675,7 @@ func (s *Server) processManual(w http.ResponseWriter, r *http.Request) {
// --- Restore Execution --- // --- Restore Execution ---
func (s *Server) executeLocalRestore(drivePath string) { func (s *Server) executeLocalRestore(drivePath, historyFile string) {
s.restoreMu.Lock() s.restoreMu.Lock()
s.restoreRunning = true s.restoreRunning = true
s.restoreDone = false s.restoreDone = false
@@ -630,8 +687,14 @@ func (s *Server) executeLocalRestore(drivePath string) {
} }
s.restoreMu.Unlock() s.restoreMu.Unlock()
// Step 1: Read backup // Step 1: Read backup (current or historical version)
backupData, _, err := backup.ReadLocalInfraBackup(drivePath) var backupData []byte
var err error
if historyFile != "" {
backupData, _, err = backup.ReadLocalInfraBackupFromHistory(drivePath, historyFile)
} else {
backupData, _, err = backup.ReadLocalInfraBackup(drivePath)
}
if err != nil { if err != nil {
s.setRestoreError(0, fmt.Sprintf("Mentés olvasási hiba: %v", err)) s.setRestoreError(0, fmt.Sprintf("Mentés olvasási hiba: %v", err))
return return
+62 -29
View File
@@ -17,15 +17,20 @@ import (
// DriveBackup represents a found infra backup on a drive. // DriveBackup represents a found infra backup on a drive.
type DriveBackup struct { type DriveBackup struct {
Device string `json:"device"` Device string `json:"device"`
Label string `json:"label"` Label string `json:"label"`
MountPoint string `json:"mount_point"` MountPoint string `json:"mount_point"`
CustomerID string `json:"customer_id"` CustomerID string `json:"customer_id"`
Timestamp string `json:"timestamp"` Timestamp string `json:"timestamp"`
CtrlVersion string `json:"controller_version"` CtrlVersion string `json:"controller_version"`
IntegrityOK bool `json:"integrity_ok"` IntegrityOK bool `json:"integrity_ok"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
WasTempMounted bool `json:"-"` StackCount int `json:"stack_count"`
StackNames []string `json:"stack_names,omitempty"`
DiskCount int `json:"disk_count"`
IsHistory bool `json:"is_history"`
HistoryFile string `json:"history_file,omitempty"`
WasTempMounted bool `json:"-"`
} }
// lsblkOutput represents the JSON output of lsblk. // lsblkOutput represents the JSON output of lsblk.
@@ -114,10 +119,8 @@ func ScanDrivesForInfraBackups(logger *log.Logger, debug bool) ([]DriveBackup, e
continue continue
} }
result := scanPartition(part, mountedFS, logger) partResults := scanPartition(part, mountedFS, logger)
if result != nil { results = append(results, partResults...)
results = append(results, *result)
}
} }
logger.Printf("[INFO] Setup: drive scan complete — found %d backup(s)", countValid(results)) logger.Printf("[INFO] Setup: drive scan complete — found %d backup(s)", countValid(results))
@@ -137,7 +140,7 @@ func CleanupTempMounts(results []DriveBackup, logger *log.Logger) {
} }
} }
func scanPartition(part lsblkDevice, mountedFS map[string]string, logger *log.Logger) *DriveBackup { func scanPartition(part lsblkDevice, mountedFS map[string]string, logger *log.Logger) []DriveBackup {
label := "" label := ""
if part.Label != nil { if part.Label != nil {
label = *part.Label label = *part.Label
@@ -187,10 +190,12 @@ func scanPartition(part lsblkDevice, mountedFS map[string]string, logger *log.Lo
return nil return nil
} }
// Found backup — read and validate var results []DriveBackup
_, meta, err := backup.ReadLocalInfraBackup(mountPoint)
result := &DriveBackup{ // Read current backup
backupData, meta, err := backup.ReadLocalInfraBackup(mountPoint)
current := DriveBackup{
Device: part.Path, Device: part.Path,
Label: label, Label: label,
MountPoint: mountPoint, MountPoint: mountPoint,
@@ -198,24 +203,52 @@ func scanPartition(part lsblkDevice, mountedFS map[string]string, logger *log.Lo
} }
if err != nil { if err != nil {
result.IntegrityOK = false current.IntegrityOK = false
result.Error = err.Error() current.Error = err.Error()
if meta != nil { if meta != nil {
result.CustomerID = meta.CustomerID current.CustomerID = meta.CustomerID
result.Timestamp = meta.Timestamp current.Timestamp = meta.Timestamp
result.CtrlVersion = meta.ControllerVersion current.CtrlVersion = meta.ControllerVersion
} }
} else { } else {
result.IntegrityOK = true current.IntegrityOK = true
result.CustomerID = meta.CustomerID current.CustomerID = meta.CustomerID
result.Timestamp = meta.Timestamp current.Timestamp = meta.Timestamp
result.CtrlVersion = meta.ControllerVersion current.CtrlVersion = meta.ControllerVersion
backup.ParseBackupCounts(backupData, &current.StackCount, &current.StackNames, &current.DiskCount)
} }
logger.Printf("[INFO] Setup: found infra backup on %s (%s) — customer=%s, integrity=%v", results = append(results, current)
part.Path, label, result.CustomerID, result.IntegrityOK)
return result logger.Printf("[INFO] Setup: found infra backup on %s (%s) — customer=%s, integrity=%v",
part.Path, label, current.CustomerID, current.IntegrityOK)
// Also scan history directory for older versions
history := backup.ReadLocalInfraHistory(mountPoint)
for _, hv := range history {
hResult := DriveBackup{
Device: part.Path,
Label: label,
MountPoint: mountPoint,
CustomerID: hv.CustomerID,
Timestamp: hv.Timestamp,
CtrlVersion: hv.ControllerVersion,
IntegrityOK: hv.IntegrityOK,
Error: hv.Error,
StackCount: hv.StackCount,
StackNames: hv.StackNames,
DiskCount: hv.DiskCount,
IsHistory: true,
HistoryFile: hv.HistoryFile,
}
results = append(results, hResult)
}
if len(history) > 0 {
logger.Printf("[INFO] Setup: found %d historical backup version(s) on %s", len(history), part.Path)
}
return results
} }
func readMountedFilesystems() map[string]string { func readMountedFilesystems() map[string]string {
@@ -0,0 +1,56 @@
{{define "setup_hub_versions"}}
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mentés kiválasztása — Felhom</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="setup-container">
<div class="setup-header">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
<h1>Visszaállítás a Hub-ról</h1>
<p style="color: var(--text-secondary, #8b949e);">Válasszon a mentés-verziók közül.</p>
</div>
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
<div class="setup-card">
<form method="POST" action="/setup/hub-restore/select" id="version-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr>
<th style="width: 2rem;"></th>
<th style="text-align: left; padding: 0.5rem;">Dátum</th>
<th style="text-align: left; padding: 0.5rem;">Alkalmazások</th>
<th style="text-align: right; padding: 0.5rem;">Lemezek</th>
</tr>
</thead>
<tbody>
{{range $i, $v := .Versions}}
<tr style="border-top: 1px solid var(--border, #30363d);">
<td style="padding: 0.5rem;">
<input type="radio" name="version_id" value="{{$v.ID}}" {{if eq $i 0}}checked{{end}}>
</td>
<td style="padding: 0.5rem;">{{$v.CreatedAt}}{{if eq $i 0}} <span class="badge badge-ok" style="font-size: 0.75em;">legújabb</span>{{end}}</td>
<td style="padding: 0.5rem;">
{{$v.StackCount}}{{if $v.StackNames}}: {{range $j, $n := $v.StackNames}}{{if $j}}, {{end}}{{$n}}{{end}}{{end}}
</td>
<td style="text-align: right; padding: 0.5rem;">{{$v.DiskCount}}</td>
</tr>
{{end}}
</tbody>
</table>
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
<button type="submit" class="btn btn-primary">Visszaállítás</button>
<a href="/setup" class="btn btn-outline">Vissza</a>
</div>
</form>
</div>
</div>
</body>
</html>
{{end}}
@@ -33,6 +33,8 @@
<th>Ügyfél</th> <th>Ügyfél</th>
<th>Dátum</th> <th>Dátum</th>
<th>Verzió</th> <th>Verzió</th>
<th>Alkalmazások</th>
<th>Lemezek</th>
<th>Állapot</th> <th>Állapot</th>
</tr> </tr>
</thead> </thead>
@@ -44,6 +46,7 @@
<input type="hidden" name="_csrf" value="{{.CSRF}}"> <input type="hidden" name="_csrf" value="{{.CSRF}}">
<input type="hidden" name="source" value="local"> <input type="hidden" name="source" value="local">
<input type="hidden" name="drive_path" id="selected-drive" value=""> <input type="hidden" name="drive_path" id="selected-drive" value="">
<input type="hidden" name="history_file" id="selected-history" value="">
<button type="submit" class="btn btn-primary" id="restore-btn" disabled>Visszaállítás</button> <button type="submit" class="btn btn-primary" id="restore-btn" disabled>Visszaállítás</button>
</form> </form>
<a href="/setup/hub-restore" class="btn btn-outline">Tovább a Hub-hoz</a> <a href="/setup/hub-restore" class="btn btn-outline">Tovább a Hub-hoz</a>
@@ -69,7 +72,6 @@
<script> <script>
(function() { (function() {
var selectedDrive = '';
function poll() { function poll() {
fetch('/setup/scan/status') fetch('/setup/scan/status')
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
@@ -95,13 +97,31 @@
var validCount = 0; var validCount = 0;
data.results.forEach(function(r, i) { data.results.forEach(function(r, i) {
var tr = document.createElement('tr'); var tr = document.createElement('tr');
var radio = r.integrity_ok ? '<input type="radio" name="backup" value="' + r.mount_point + '" onclick="selectDrive(this)">' : ''; var driveVal = r.mount_point + '|' + (r.history_file || '');
var radio = r.integrity_ok ? '<input type="radio" name="backup" value="' + driveVal + '" onclick="selectDrive(this)">' : '';
var apps = '-';
if (r.stack_count > 0) {
apps = r.stack_count.toString();
if (r.stack_names && r.stack_names.length > 0) {
var names = r.stack_names.slice(0, 3).join(', ');
if (r.stack_names.length > 3) names += ', ...';
apps += ': ' + names;
}
}
var disks = r.disk_count > 0 ? r.disk_count.toString() : '-';
var dateBadge = '';
if (r.is_history) dateBadge = ' <span class="badge" style="font-size:0.7em;background:#6e4000;color:#ffd080;">korábbi</span>';
var statusCol = r.integrity_ok
? '<span class="badge badge-ok">OK</span>'
: '<span class="badge badge-error">' + (r.error || 'Hiba') + '</span>';
tr.innerHTML = '<td>' + radio + '</td>' + tr.innerHTML = '<td>' + radio + '</td>' +
'<td>' + (r.device || '') + (r.label ? ' (' + r.label + ')' : '') + '</td>' + '<td>' + (r.device || '') + (r.label ? ' (' + r.label + ')' : '') + '</td>' +
'<td>' + (r.customer_id || '-') + '</td>' + '<td>' + (r.customer_id || '-') + '</td>' +
'<td>' + (r.timestamp ? r.timestamp.substring(0, 10) : '-') + '</td>' + '<td>' + (r.timestamp ? r.timestamp.substring(0, 10) : '-') + dateBadge + '</td>' +
'<td>' + (r.controller_version || '-') + '</td>' + '<td>' + (r.controller_version || '-') + '</td>' +
'<td>' + (r.integrity_ok ? '<span class="badge badge-ok">OK</span>' : '<span class="badge badge-error">' + (r.error || 'Hiba') + '</span>') + '</td>'; '<td>' + apps + '</td>' +
'<td>' + disks + '</td>' +
'<td>' + statusCol + '</td>';
tbody.appendChild(tr); tbody.appendChild(tr);
if (r.integrity_ok) validCount++; if (r.integrity_ok) validCount++;
}); });
@@ -112,7 +132,9 @@
}); });
} }
window.selectDrive = function(el) { window.selectDrive = function(el) {
document.getElementById('selected-drive').value = el.value; var parts = el.value.split('|');
document.getElementById('selected-drive').value = parts[0];
document.getElementById('selected-history').value = parts[1] || '';
document.getElementById('restore-btn').disabled = false; document.getElementById('restore-btn').disabled = false;
}; };
poll(); poll();