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:
+239
-22
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user