diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 3b75528..71ea9b8 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,5 +1,17 @@ # Felhom Hub — Changelog +## v0.6.2 (2026-02-26) + +### Added +- **Infra backup GFS retention** — New `infra_backup_versions` table stores multiple backups per customer. GFS pruning keeps: all from last 24h, latest per day (7 days), latest per week (4 weeks), latest per month (3 months) — ~14 versions max per customer +- **`GET /api/v1/infra-backup/{id}/versions`** — Returns metadata list of all retained backup versions (date, stack names, disk count) for a customer. Bearer auth. +- **Recovery version selection** — `GET /api/v1/recovery/{id}?version=ID` fetches a specific backup version instead of latest. Response now includes `backup_versions` array with all available versions. +- **Dashboard backup history** — Customer detail page "Infra Backup" card shows version count and collapsible history table (date, apps, disks) + +### Changed +- **`SaveInfraBackup()`** — Now INSERTs a new row instead of upserting, preserving history. Automatically prunes old versions via GFS algorithm. +- **One-time migration** — Existing data from `infra_backups` table is copied to `infra_backup_versions` on first startup + ## v0.6.1 (2026-02-25) ### Added diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index e2168f1..357fc5a 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -102,6 +102,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.handleNotify(w, r) case r.Method == http.MethodPost && path == "/infra-backup": h.handleInfraBackupPush(w, r) + case r.Method == http.MethodGet && strings.HasPrefix(path, "/infra-backup/") && strings.HasSuffix(path, "/versions"): + customerID := strings.TrimPrefix(path, "/infra-backup/") + customerID = strings.TrimSuffix(customerID, "/versions") + h.handleInfraBackupVersions(w, r, customerID) case r.Method == http.MethodGet && strings.HasPrefix(path, "/infra-backup/"): h.handleInfraBackupGet(w, r, strings.TrimPrefix(path, "/infra-backup/")) case r.Method == http.MethodPost && path == "/preferences": @@ -586,6 +590,33 @@ func (h *Handler) handleInfraBackupGet(w http.ResponseWriter, r *http.Request, c w.Write(data) } +// handleInfraBackupVersions returns a list of backup versions for a customer. +// Auth: Bearer token. +func (h *Handler) handleInfraBackupVersions(w http.ResponseWriter, r *http.Request, customerID string) { + if !h.checkAuth(r) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if customerID == "" { + http.Error(w, "Missing customer_id", http.StatusBadRequest) + return + } + + versions, err := h.store.ListInfraBackupVersions(customerID) + if err != nil { + h.logger.Printf("[ERROR] Failed to list infra backup versions for %s: %v", customerID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + if versions == nil { + versions = []store.InfraBackupVersion{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(versions) +} + // handleRecovery returns both the generated controller.yaml and the infra backup for disaster recovery. // Auth: X-Retrieval-Password header (same as config retrieval). func (h *Handler) handleRecovery(w http.ResponseWriter, r *http.Request, customerID string) { @@ -631,24 +662,44 @@ func (h *Handler) handleRecovery(w http.ResponseWriter, r *http.Request, custome // Fetch infra backup (optional — may not exist for new customers) var infraBackup json.RawMessage hasInfraBackup := false - if data, err := h.store.GetInfraBackup(customerID); err == nil && data != nil { - infraBackup = data - hasInfraBackup = true + + // Support ?version=ID for selecting a specific backup version + if versionStr := r.URL.Query().Get("version"); versionStr != "" { + var versionID int64 + if _, err := fmt.Sscanf(versionStr, "%d", &versionID); err == nil { + if data, err := h.store.GetInfraBackupByID(versionID); err == nil && data != nil { + infraBackup = data + hasInfraBackup = true + } + } + } else { + if data, err := h.store.GetInfraBackup(customerID); err == nil && data != nil { + infraBackup = data + hasInfraBackup = true + } + } + + // Include version list for version picker + var backupVersions []store.InfraBackupVersion + if versions, err := h.store.ListInfraBackupVersions(customerID); err == nil { + backupVersions = versions } resp := struct { - CustomerID string `json:"customer_id"` - ConfigYAML string `json:"config_yaml"` - InfraBackup json.RawMessage `json:"infra_backup"` - HasInfraBackup bool `json:"has_infra_backup"` + CustomerID string `json:"customer_id"` + ConfigYAML string `json:"config_yaml"` + InfraBackup json.RawMessage `json:"infra_backup"` + HasInfraBackup bool `json:"has_infra_backup"` + BackupVersions []store.InfraBackupVersion `json:"backup_versions,omitempty"` }{ CustomerID: customerID, ConfigYAML: configYAML, InfraBackup: infraBackup, HasInfraBackup: hasInfraBackup, + BackupVersions: backupVersions, } - h.logger.Printf("[INFO] Recovery data downloaded for customer %s (has_infra_backup=%v)", customerID, hasInfraBackup) + h.logger.Printf("[INFO] Recovery data downloaded for customer %s (has_infra_backup=%v, versions=%d)", customerID, hasInfraBackup, len(backupVersions)) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index 276eb7b..ca5bda7 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -195,6 +195,27 @@ func (s *Store) migrate() error { return err } + // v0.7.0: versioned infra backups with GFS retention + _, err = s.db.Exec(` + CREATE TABLE IF NOT EXISTS infra_backup_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id TEXT NOT NULL, + backup_json TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_ibv_customer_time + ON infra_backup_versions(customer_id, created_at DESC); + `) + if err != nil { + return err + } + + // One-time migration: copy existing single-row backups to versioned table + s.db.Exec(`INSERT INTO infra_backup_versions (customer_id, backup_json, created_at) + SELECT customer_id, backup_json, updated_at FROM infra_backups + WHERE NOT EXISTS (SELECT 1 FROM infra_backup_versions + WHERE infra_backup_versions.customer_id = infra_backups.customer_id)`) + return nil } @@ -517,24 +538,34 @@ func (s *Store) GetCustomerHistory(customerID string, since time.Duration) ([]Cu return history, rows.Err() } -// SaveInfraBackup upserts the infrastructure backup for a customer. +// SaveInfraBackup inserts a new infra backup version and prunes old ones (GFS retention). func (s *Store) SaveInfraBackup(customerID string, backupJSON []byte) error { _, err := s.db.Exec(` - INSERT INTO infra_backups (customer_id, backup_json, updated_at) + INSERT INTO infra_backup_versions (customer_id, backup_json, created_at) + VALUES (?, ?, datetime('now'))`, + customerID, string(backupJSON), + ) + if err != nil { + return err + } + + // Also maintain legacy table for backward compatibility during rollback window + s.db.Exec(`INSERT INTO infra_backups (customer_id, backup_json, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(customer_id) DO UPDATE SET backup_json = excluded.backup_json, updated_at = datetime('now')`, - customerID, string(backupJSON), - ) - return err + customerID, string(backupJSON)) + + s.pruneInfraBackups(customerID) + return nil } -// GetInfraBackup returns the raw infra backup JSON for a customer, or nil if not found. +// GetInfraBackup returns the latest infra backup JSON for a customer, or nil if not found. func (s *Store) GetInfraBackup(customerID string) ([]byte, error) { var data string err := s.db.QueryRow( - "SELECT backup_json FROM infra_backups WHERE customer_id = ?", + "SELECT backup_json FROM infra_backup_versions WHERE customer_id = ? ORDER BY created_at DESC LIMIT 1", customerID, ).Scan(&data) if err == sql.ErrNoRows { @@ -546,20 +577,36 @@ func (s *Store) GetInfraBackup(customerID string) ([]byte, error) { return []byte(data), nil } +// GetInfraBackupByID returns the infra backup JSON for a specific version ID, or nil if not found. +func (s *Store) GetInfraBackupByID(id int64) ([]byte, error) { + var data string + err := s.db.QueryRow( + "SELECT backup_json FROM infra_backup_versions WHERE id = ?", id, + ).Scan(&data) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return []byte(data), nil +} + // InfraBackupMeta holds summary info for the dashboard (avoids parsing full JSON). type InfraBackupMeta struct { - UpdatedAt time.Time - StackCount int - DiskCount int + UpdatedAt time.Time + StackCount int + DiskCount int + VersionCount int } -// GetInfraBackupMeta returns summary metadata for a customer's infra backup. +// GetInfraBackupMeta returns summary metadata for a customer's latest infra backup. func (s *Store) GetInfraBackupMeta(customerID string) (*InfraBackupMeta, error) { - var backupJSON, updatedAt string + var backupJSON, createdAt string err := s.db.QueryRow( - "SELECT backup_json, updated_at FROM infra_backups WHERE customer_id = ?", + "SELECT backup_json, created_at FROM infra_backup_versions WHERE customer_id = ? ORDER BY created_at DESC LIMIT 1", customerID, - ).Scan(&backupJSON, &updatedAt) + ).Scan(&backupJSON, &createdAt) if err == sql.ErrNoRows { return nil, nil } @@ -568,24 +615,194 @@ func (s *Store) GetInfraBackupMeta(customerID string) (*InfraBackupMeta, error) } meta := &InfraBackupMeta{ - UpdatedAt: parseSQLiteTime(updatedAt), + UpdatedAt: parseSQLiteTime(createdAt), } + // Count total versions + s.db.QueryRow("SELECT COUNT(*) FROM infra_backup_versions WHERE customer_id = ?", customerID).Scan(&meta.VersionCount) + // Parse just the fields we need + parseInfraBackupCounts(backupJSON, &meta.StackCount, &meta.DiskCount, nil, s.logger, customerID) + + return meta, nil +} + +// InfraBackupVersion holds summary info for a single backup version. +type InfraBackupVersion struct { + ID int64 `json:"id"` + CreatedAt time.Time `json:"created_at"` + StackCount int `json:"stack_count"` + DiskCount int `json:"disk_count"` + StackNames []string `json:"stack_names,omitempty"` +} + +// ListInfraBackupVersions returns metadata for all retained versions of a customer's backup. +func (s *Store) ListInfraBackupVersions(customerID string) ([]InfraBackupVersion, error) { + rows, err := s.db.Query( + "SELECT id, backup_json, created_at FROM infra_backup_versions WHERE customer_id = ? ORDER BY created_at DESC", + customerID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var versions []InfraBackupVersion + for rows.Next() { + var v InfraBackupVersion + var backupJSON, createdAt string + if err := rows.Scan(&v.ID, &backupJSON, &createdAt); err != nil { + return nil, err + } + v.CreatedAt = parseSQLiteTime(createdAt) + parseInfraBackupCounts(backupJSON, &v.StackCount, &v.DiskCount, &v.StackNames, nil, "") + versions = append(versions, v) + } + return versions, rows.Err() +} + +// pruneInfraBackups applies GFS retention: keep all from last 24h, latest per day (7d), +// latest per week (4w), latest per month (3mo). Delete everything else. +func (s *Store) pruneInfraBackups(customerID string) { + rows, err := s.db.Query( + "SELECT id, created_at FROM infra_backup_versions WHERE customer_id = ? ORDER BY created_at DESC", + customerID, + ) + if err != nil { + return + } + defer rows.Close() + + type entry struct { + id int64 + createdAt time.Time + } + var all []entry + for rows.Next() { + var e entry + var ts string + if err := rows.Scan(&e.id, &ts); err != nil { + return + } + e.createdAt = parseSQLiteTime(ts) + all = append(all, e) + } + + if len(all) <= 1 { + return + } + + now := time.Now().UTC() + keep := make(map[int64]bool) + seenDays := make(map[string]bool) + seenWeeks := make(map[string]bool) + seenMonths := make(map[string]bool) + + for _, e := range all { + age := now.Sub(e.createdAt) + + // Keep all from last 24h + if age < 24*time.Hour { + keep[e.id] = true + continue + } + + // Latest per calendar day for last 7 days + if age < 7*24*time.Hour { + day := e.createdAt.Format("2006-01-02") + if !seenDays[day] { + seenDays[day] = true + keep[e.id] = true + } + continue + } + + // Latest per ISO week for last 4 weeks + if age < 28*24*time.Hour { + year, week := e.createdAt.ISOWeek() + wk := fmt.Sprintf("%d-W%02d", year, week) + if !seenWeeks[wk] { + seenWeeks[wk] = true + keep[e.id] = true + } + continue + } + + // Latest per calendar month for last 3 months + if age < 90*24*time.Hour { + month := e.createdAt.Format("2006-01") + if !seenMonths[month] { + seenMonths[month] = true + keep[e.id] = true + } + continue + } + + // Older than 3 months — don't keep + } + + // Build delete list + var deleteIDs []interface{} + var placeholders []string + for _, e := range all { + if !keep[e.id] { + deleteIDs = append(deleteIDs, e.id) + placeholders = append(placeholders, "?") + } + } + + if len(deleteIDs) == 0 { + return + } + + query := "DELETE FROM infra_backup_versions WHERE id IN (" + joinStrings(placeholders, ",") + ")" + _, err = s.db.Exec(query, deleteIDs...) + if err != nil { + s.logger.Printf("[WARN] Failed to prune infra backup versions for %s: %v", customerID, err) + } else { + s.logger.Printf("[INFO] Pruned %d old infra backup version(s) for %s (kept %d)", len(deleteIDs), customerID, len(keep)) + } +} + +// parseInfraBackupCounts extracts stack/disk counts and optionally stack names from backup JSON. +func parseInfraBackupCounts(backupJSON string, stackCount, diskCount *int, stackNames *[]string, logger *log.Logger, customerID string) { var parsed struct { - DeployedStacks []json.RawMessage `json:"deployed_stacks"` - DiskLayout 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([]byte(backupJSON), &parsed); err != nil { - s.logger.Printf("[WARN] Failed to parse infra backup metadata for %s: %v", customerID, err) - } else { - meta.StackCount = len(parsed.DeployedStacks) - meta.DiskCount = len(parsed.DiskLayout.Mounts) + if logger != nil { + logger.Printf("[WARN] Failed to parse infra backup metadata for %s: %v", customerID, err) + } + 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) + } + } +} - return meta, nil +func joinStrings(ss []string, sep string) string { + if len(ss) == 0 { + return "" + } + result := ss[0] + for _, s := range ss[1:] { + result += sep + s + } + return result } // Prune deletes reports older than the given number of days. diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index b74c42e..b94e386 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -231,6 +231,7 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c var recentNotifs []store.NotificationLogEntry var infraMeta *store.InfraBackupMeta var infraBackupAge string + var infraBackupVersions []store.InfraBackupVersion var events []store.Event var eventCounts map[string]int @@ -244,6 +245,7 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c if infraMeta != nil { infraBackupAge = timeAgo(infraMeta.UpdatedAt) } + infraBackupVersions, _ = s.store.ListInfraBackupVersions(customerID) events, _ = s.store.GetRecentEvents(customerID, 50) eventCounts, _ = s.store.CountEventsBySeverity(customerID, time.Now().Add(-24*time.Hour)) appTelemetry, _ = s.store.GetCustomerAppSummary(customerID, time.Now().Add(-7*24*time.Hour)) @@ -274,6 +276,7 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c InfraBackup *store.InfraBackupMeta InfraBackupAge string + InfraBackupVersions []store.InfraBackupVersion NotifPrefs *store.NotificationPrefs RecentNotifications []store.NotificationLogEntry History []store.CustomerSummary @@ -315,6 +318,7 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c InfraBackup: infraMeta, InfraBackupAge: infraBackupAge, + InfraBackupVersions: infraBackupVersions, NotifPrefs: notifPrefs, RecentNotifications: recentNotifs, History: history, diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html index f917d14..a4c5632 100644 --- a/hub/internal/web/templates/customer_unified.html +++ b/hub/internal/web/templates/customer_unified.html @@ -313,7 +313,34 @@ Disks {{.InfraBackup.DiskCount}} +
+ Versions + {{.InfraBackup.VersionCount}} +
+ {{if .InfraBackupVersions}} +
+ Backup History ({{len .InfraBackupVersions}} versions) + + + + + + + + + + {{range .InfraBackupVersions}} + + + + + + {{end}} + +
DateAppsDisks
{{.CreatedAt.Format "2006-01-02 15:04"}}{{.StackCount}}{{if .StackNames}}: {{range $i, $n := .StackNames}}{{if $i}}, {{end}}{{$n}}{{end}}{{end}}{{.DiskCount}}
+
+ {{end}} {{else}}

No infra backup received yet

{{end}}