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,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)
|
||||
|
||||
Reference in New Issue
Block a user