hub v0.1.7: Infrastructure backup endpoints for disaster recovery

Add infra-backup push/pull API for controller DR:
- POST /api/v1/infra-backup — controller pushes infrastructure snapshot
- GET /api/v1/infra-backup/{customer_id} — fresh controller pulls backup
- infra_backups SQLite table with per-customer snapshots
- Customer detail page shows infra backup status card
- README.md with full API docs and DR flow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 13:17:12 +01:00
parent d3d3044b98
commit 41e313bf36
5 changed files with 343 additions and 0 deletions
+75
View File
@@ -91,6 +91,12 @@ func (s *Store) migrate() error {
CREATE INDEX IF NOT EXISTS idx_notification_log_customer
ON notification_log(customer_id, created_at DESC);
CREATE TABLE IF NOT EXISTS infra_backups (
customer_id TEXT PRIMARY KEY,
backup_json TEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
`)
return err
}
@@ -380,6 +386,75 @@ func (s *Store) GetCustomerHistory(customerID string, since time.Duration) ([]Cu
return history, rows.Err()
}
// SaveInfraBackup upserts the infrastructure backup for a customer.
func (s *Store) SaveInfraBackup(customerID string, backupJSON []byte) error {
_, err := 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
}
// GetInfraBackup returns the raw 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 = ?",
customerID,
).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
}
// GetInfraBackupMeta returns summary metadata for a customer's infra backup.
func (s *Store) GetInfraBackupMeta(customerID string) (*InfraBackupMeta, error) {
var backupJSON, updatedAt string
err := s.db.QueryRow(
"SELECT backup_json, updated_at FROM infra_backups WHERE customer_id = ?",
customerID,
).Scan(&backupJSON, &updatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
meta := &InfraBackupMeta{
UpdatedAt: parseSQLiteTime(updatedAt),
}
// Parse just the fields we need
var parsed struct {
DeployedStacks []json.RawMessage `json:"deployed_stacks"`
DiskLayout struct {
Mounts []json.RawMessage `json:"mounts"`
} `json:"disk_layout"`
}
if json.Unmarshal([]byte(backupJSON), &parsed) == nil {
meta.StackCount = len(parsed.DeployedStacks)
meta.DiskCount = len(parsed.DiskLayout.Mounts)
}
return meta, nil
}
// Prune deletes reports older than the given number of days.
func (s *Store) Prune(maxDays int) (int64, error) {
cutoff := time.Now().AddDate(0, 0, -maxDays).Format("2006-01-02 15:04:05")