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:
+8
-1
@@ -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`)
|
||||
|
||||
+10
-7
@@ -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: `<drive>/.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://<LAN-IP>: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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>Dátum</th>
|
||||
<th>Verzió</th>
|
||||
<th>Alkalmazások</th>
|
||||
<th>Lemezek</th>
|
||||
<th>Állapot</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -44,6 +46,7 @@
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<input type="hidden" name="source" value="local">
|
||||
<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>
|
||||
</form>
|
||||
<a href="/setup/hub-restore" class="btn btn-outline">Tovább a Hub-hoz</a>
|
||||
@@ -69,7 +72,6 @@
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var selectedDrive = '';
|
||||
function poll() {
|
||||
fetch('/setup/scan/status')
|
||||
.then(function(r) { return r.json(); })
|
||||
@@ -95,13 +97,31 @@
|
||||
var validCount = 0;
|
||||
data.results.forEach(function(r, i) {
|
||||
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>' +
|
||||
'<td>' + (r.device || '') + (r.label ? ' (' + r.label + ')' : '') + '</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.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);
|
||||
if (r.integrity_ok) validCount++;
|
||||
});
|
||||
@@ -112,7 +132,9 @@
|
||||
});
|
||||
}
|
||||
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;
|
||||
};
|
||||
poll();
|
||||
|
||||
Reference in New Issue
Block a user