feat: infra backup GFS retention + version history

New infra_backup_versions table with GFS pruning (~14 versions per
customer). Recovery endpoint supports ?version=ID. New /versions API.
Dashboard shows collapsible backup history with app names and disk count.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 14:47:48 +01:00
parent f82fa9be2c
commit f1212e6ba8
5 changed files with 341 additions and 30 deletions
+59 -8
View File
@@ -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)
}