From c0cdd95e5687e5445ca95f1b3d27a0dc40143512 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 26 Feb 2026 14:47:40 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 9 +- controller/README.md | 17 +- controller/internal/backup/local_infra.go | 229 +++++++++++++++++- controller/internal/backup/paths.go | 5 + controller/internal/report/infra_pull.go | 60 ++++- controller/internal/setup/handlers.go | 121 ++++++--- controller/internal/setup/scanner.go | 91 ++++--- .../setup/templates/setup_hub_versions.html | 56 +++++ .../internal/setup/templates/setup_scan.html | 32 ++- 9 files changed, 540 insertions(+), 80 deletions(-) create mode 100644 controller/internal/setup/templates/setup_hub_versions.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 40480e7..24ee3b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,22 @@ ## 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 - **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 +- **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 - **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) - **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 - **Bind mount write**: `atomicWriteFile()` now falls back to direct write when rename fails (fixes "device or resource busy" on Docker bind-mounted `controller.yaml`) diff --git a/controller/README.md b/controller/README.md index 45517b3..7ab5976 100644 --- a/controller/README.md +++ b/controller/README.md @@ -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/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/csrf.go` | Lightweight CSRF protection (cookie + hidden field, `SameSite=Strict`) | | `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`) The controller writes infrastructure snapshots to **every connected drive** after each backup cycle and on startup. Location: `/.felhom-infra-backup/`. Files: - `backup.json` — full infra backup (config, settings, disk layout, passwords, stacks) - `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`) @@ -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://:8081 4. Hub mode: welcome page shows Hub restore / local scan / fresh install Non-hub mode: welcome page shows restore / fresh install -5. Hub restore: auto-connects to Hub, shows infra backup details - Local restore: scans all drives for .felhom-infra-backup/ directories -6. One-click restore: config, settings, passwords, disk layout +5. Hub restore: auto-connects to Hub, shows version picker if multiple versions + Local restore: scans all drives for .felhom-infra-backup/ directories (current + history/) +6. User selects backup version → restore: config, settings, passwords, disk layout 7. Controller restarts into normal mode with full config 8. Controller auto-mounts surviving drives by UUID from disk layout 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):** 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 **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). diff --git a/controller/internal/backup/local_infra.go b/controller/internal/backup/local_infra.go index 36e7e46..04f6a46 100644 --- a/controller/internal/backup/local_infra.go +++ b/controller/internal/backup/local_infra.go @@ -8,11 +8,17 @@ import ( "log" "os" "path/filepath" + "sort" + "strings" + "time" ) // MaxSchemaVersion is the highest infra backup schema version this controller can read. 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. type InfraMetadata struct { SchemaVersion int `json:"schema_version"` @@ -58,7 +64,7 @@ func WriteLocalInfraBackup(backupJSON []byte, customerID, controllerVersion, tim if debug { 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) 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)) } -// writeInfraToDir writes backup.json and metadata.json atomically to the given directory. -func writeInfraToDir(dir string, backupData, metaData []byte) error { +// writeInfraToDir rotates the current backup into history/ then writes new backup.json and metadata.json. +func writeInfraToDir(dir string, backupData, metaData []byte, logger *log.Logger) error { if err := os.MkdirAll(dir, 0700); err != nil { return fmt.Errorf("creating dir: %w", err) } + // Rotate current backup to history (best-effort) + rotateToHistory(dir, logger) + // Write backup.json atomically backupPath := filepath.Join(dir, "backup.json") if err := atomicWrite(backupPath, backupData, 0600); err != nil { @@ -92,6 +101,106 @@ func writeInfraToDir(dir string, backupData, metaData []byte) error { 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. func atomicWrite(path string, data []byte, perm os.FileMode) error { 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. func ReadLocalInfraBackup(mountPath string) ([]byte, *InfraMetadata, error) { 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") + 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) if err != nil { return nil, nil, fmt.Errorf("reading metadata.json: %w", err) @@ -129,7 +349,6 @@ func ReadLocalInfraBackup(mountPath string) ([]byte, *InfraMetadata, error) { } // Read backup data - backupPath := filepath.Join(dir, "backup.json") backupData, err := os.ReadFile(backupPath) if err != nil { return nil, &meta, fmt.Errorf("reading backup.json: %w", err) diff --git a/controller/internal/backup/paths.go b/controller/internal/backup/paths.go index 2d7e54e..2bd1fd9 100644 --- a/controller/internal/backup/paths.go +++ b/controller/internal/backup/paths.go @@ -49,3 +49,8 @@ func AppDataDir(drivePath, stackName string) string { func InfraBackupDir(mountPath string) string { 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") +} diff --git a/controller/internal/report/infra_pull.go b/controller/internal/report/infra_pull.go index d77bffe..f3e5948 100644 --- a/controller/internal/report/infra_pull.go +++ b/controller/internal/report/infra_pull.go @@ -18,12 +18,22 @@ var ( 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. type RecoveryResponse struct { - CustomerID string `json:"customer_id"` - ConfigYAML string `json:"config_yaml"` - InfraBackup *InfraBackup `json:"infra_backup"` - HasInfraBackup bool `json:"has_infra_backup"` + CustomerID string `json:"customer_id"` + ConfigYAML string `json:"config_yaml"` + InfraBackup *InfraBackup `json:"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). @@ -69,6 +79,48 @@ func PullRecovery(hubURL, customerID, retrievalPassword string) (*RecoveryRespon 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. // Auth: X-Retrieval-Password header. func PullConfig(hubURL, customerID, retrievalPassword string) (string, error) { diff --git a/controller/internal/setup/handlers.go b/controller/internal/setup/handlers.go index b2a0a0d..1f5de37 100644 --- a/controller/internal/setup/handlers.go +++ b/controller/internal/setup/handlers.go @@ -115,6 +115,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/setup/scan", s.handleScan) mux.HandleFunc("/setup/scan/status", s.handleScanStatus) 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/status", s.handleRestoreStatus) mux.HandleFunc("/setup/fresh", s.handleFreshHub) @@ -233,7 +234,8 @@ func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) { switch source { case "local": drivePath := r.FormValue("drive_path") - go s.executeLocalRestore(drivePath) + historyFile := r.FormValue("history_file") + go s.executeLocalRestore(drivePath, historyFile) case "hub": go s.executeHubRestore() default: @@ -372,10 +374,31 @@ func (s *Server) autoProcessHubRestore(w http.ResponseWriter, r *http.Request, c } 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{ Source: "hub", CustomerID: customerID, @@ -389,9 +412,8 @@ func (s *Server) autoProcessHubRestore(w http.ResponseWriter, r *http.Request, c s.state.SetStep("restore-exec") 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() csrf := ensureCSRFToken(w, r) @@ -476,34 +498,69 @@ func (s *Server) processHubRestore(w http.ResponseWriter, r *http.Request) { } 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("hub_config_yaml", recovery.ConfigYAML) - if recovery.HasInfraBackup && recovery.InfraBackup != nil { - ibJSON, _ := json.Marshal(recovery.InfraBackup) - s.state.SetFormField("hub_infra_backup", string(ibJSON)) - s.state.SelectedBackup.Timestamp = recovery.InfraBackup.Timestamp + + // If multiple versions available, show picker + if len(recovery.BackupVersions) > 1 && recovery.HasInfraBackup { + s.logger.Printf("[INFO] Setup: %d backup versions available — showing version picker", len(recovery.BackupVersions)) + 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 - go s.executeHubRestore() - - csrf := ensureCSRFToken(w, r) - data := map[string]interface{}{ - "CSRF": csrf, +// handleHubVersionSelect processes the user's version selection from the Hub version picker. +func (s *Server) handleHubVersionSelect(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Redirect(w, r, "/setup/hub-restore", http.StatusFound) + return } - 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) { @@ -618,7 +675,7 @@ func (s *Server) processManual(w http.ResponseWriter, r *http.Request) { // --- Restore Execution --- -func (s *Server) executeLocalRestore(drivePath string) { +func (s *Server) executeLocalRestore(drivePath, historyFile string) { s.restoreMu.Lock() s.restoreRunning = true s.restoreDone = false @@ -630,8 +687,14 @@ func (s *Server) executeLocalRestore(drivePath string) { } s.restoreMu.Unlock() - // Step 1: Read backup - backupData, _, err := backup.ReadLocalInfraBackup(drivePath) + // Step 1: Read backup (current or historical version) + var backupData []byte + var err error + if historyFile != "" { + backupData, _, err = backup.ReadLocalInfraBackupFromHistory(drivePath, historyFile) + } else { + backupData, _, err = backup.ReadLocalInfraBackup(drivePath) + } if err != nil { s.setRestoreError(0, fmt.Sprintf("Mentés olvasási hiba: %v", err)) return diff --git a/controller/internal/setup/scanner.go b/controller/internal/setup/scanner.go index 7839c2e..55885aa 100644 --- a/controller/internal/setup/scanner.go +++ b/controller/internal/setup/scanner.go @@ -17,15 +17,20 @@ import ( // DriveBackup represents a found infra backup on a drive. type DriveBackup struct { - Device string `json:"device"` - Label string `json:"label"` - MountPoint string `json:"mount_point"` - CustomerID string `json:"customer_id"` - Timestamp string `json:"timestamp"` - CtrlVersion string `json:"controller_version"` - IntegrityOK bool `json:"integrity_ok"` - Error string `json:"error,omitempty"` - WasTempMounted bool `json:"-"` + Device string `json:"device"` + Label string `json:"label"` + MountPoint string `json:"mount_point"` + CustomerID string `json:"customer_id"` + Timestamp string `json:"timestamp"` + CtrlVersion 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"` + IsHistory bool `json:"is_history"` + HistoryFile string `json:"history_file,omitempty"` + WasTempMounted bool `json:"-"` } // lsblkOutput represents the JSON output of lsblk. @@ -114,10 +119,8 @@ func ScanDrivesForInfraBackups(logger *log.Logger, debug bool) ([]DriveBackup, e continue } - result := scanPartition(part, mountedFS, logger) - if result != nil { - results = append(results, *result) - } + partResults := scanPartition(part, mountedFS, logger) + results = append(results, partResults...) } 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 := "" if part.Label != nil { label = *part.Label @@ -187,10 +190,12 @@ func scanPartition(part lsblkDevice, mountedFS map[string]string, logger *log.Lo return nil } - // Found backup — read and validate - _, meta, err := backup.ReadLocalInfraBackup(mountPoint) + var results []DriveBackup - result := &DriveBackup{ + // Read current backup + backupData, meta, err := backup.ReadLocalInfraBackup(mountPoint) + + current := DriveBackup{ Device: part.Path, Label: label, MountPoint: mountPoint, @@ -198,24 +203,52 @@ func scanPartition(part lsblkDevice, mountedFS map[string]string, logger *log.Lo } if err != nil { - result.IntegrityOK = false - result.Error = err.Error() + current.IntegrityOK = false + current.Error = err.Error() if meta != nil { - result.CustomerID = meta.CustomerID - result.Timestamp = meta.Timestamp - result.CtrlVersion = meta.ControllerVersion + current.CustomerID = meta.CustomerID + current.Timestamp = meta.Timestamp + current.CtrlVersion = meta.ControllerVersion } } else { - result.IntegrityOK = true - result.CustomerID = meta.CustomerID - result.Timestamp = meta.Timestamp - result.CtrlVersion = meta.ControllerVersion + current.IntegrityOK = true + current.CustomerID = meta.CustomerID + current.Timestamp = meta.Timestamp + current.CtrlVersion = meta.ControllerVersion + backup.ParseBackupCounts(backupData, ¤t.StackCount, ¤t.StackNames, ¤t.DiskCount) } - logger.Printf("[INFO] Setup: found infra backup on %s (%s) — customer=%s, integrity=%v", - part.Path, label, result.CustomerID, result.IntegrityOK) + results = append(results, current) - 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 { diff --git a/controller/internal/setup/templates/setup_hub_versions.html b/controller/internal/setup/templates/setup_hub_versions.html new file mode 100644 index 0000000..c165dd8 --- /dev/null +++ b/controller/internal/setup/templates/setup_hub_versions.html @@ -0,0 +1,56 @@ +{{define "setup_hub_versions"}} + + + + + + Mentés kiválasztása — Felhom + + + +
+
+ Felhom.eu +

Visszaállítás a Hub-ról

+

Válasszon a mentés-verziók közül.

+
+ + {{if .Error}}
{{.Error}}
{{end}} + +
+
+ + + + + + + + + + + + {{range $i, $v := .Versions}} + + + + + + + {{end}} + +
DátumAlkalmazásokLemezek
+ + {{$v.CreatedAt}}{{if eq $i 0}} legújabb{{end}} + {{$v.StackCount}}{{if $v.StackNames}}: {{range $j, $n := $v.StackNames}}{{if $j}}, {{end}}{{$n}}{{end}}{{end}} + {{$v.DiskCount}}
+
+ + Vissza +
+
+
+
+ + +{{end}} diff --git a/controller/internal/setup/templates/setup_scan.html b/controller/internal/setup/templates/setup_scan.html index 44a8bb2..cc2ba58 100644 --- a/controller/internal/setup/templates/setup_scan.html +++ b/controller/internal/setup/templates/setup_scan.html @@ -33,6 +33,8 @@ Ügyfél Dátum Verzió + Alkalmazások + Lemezek Állapot @@ -44,6 +46,7 @@ + Tovább a Hub-hoz @@ -69,7 +72,6 @@