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:
@@ -44,6 +44,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleReport(w, r)
|
||||
case r.Method == http.MethodPost && path == "/notify":
|
||||
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/"):
|
||||
h.handleInfraBackupGet(w, r, strings.TrimPrefix(path, "/infra-backup/"))
|
||||
case r.Method == http.MethodPost && path == "/preferences":
|
||||
h.handleSavePreferences(w, r)
|
||||
case r.Method == http.MethodGet && path == "/customers":
|
||||
@@ -322,6 +326,71 @@ func (h *Handler) handleSavePreferences(w http.ResponseWriter, r *http.Request)
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
}
|
||||
|
||||
// handleInfraBackupPush stores an infrastructure snapshot from a controller.
|
||||
func (h *Handler) handleInfraBackupPush(w http.ResponseWriter, r *http.Request) {
|
||||
if h.apiKey != "" {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != h.apiKey {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
|
||||
if err != nil {
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
CustomerID string `json:"customer_id"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil || payload.CustomerID == "" {
|
||||
http.Error(w, "Invalid payload: customer_id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.SaveInfraBackup(payload.CustomerID, body); err != nil {
|
||||
h.logger.Printf("[ERROR] Failed to save infra backup for %s: %v", payload.CustomerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Printf("[INFO] Infra backup saved for %s (%d bytes)", payload.CustomerID, len(body))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
}
|
||||
|
||||
// handleInfraBackupGet returns the infrastructure backup for a customer.
|
||||
func (h *Handler) handleInfraBackupGet(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||
if h.apiKey != "" {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != h.apiKey {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if customerID == "" {
|
||||
http.Error(w, "Missing customer_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := h.store.GetInfraBackup(customerID)
|
||||
if err != nil {
|
||||
h.logger.Printf("[ERROR] Failed to get infra backup for %s: %v", customerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if data == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
// sendResendEmail sends an email via the Resend HTTP API.
|
||||
func (h *Handler) sendResendEmail(to, subject, textBody string) error {
|
||||
payload := map[string]interface{}{
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -191,6 +191,9 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request, cu
|
||||
notifPrefs, _ := s.store.GetNotificationPrefs(customerID)
|
||||
recentNotifs, _ := s.store.GetRecentNotifications(customerID, 10)
|
||||
|
||||
// Get infra backup metadata
|
||||
infraMeta, _ := s.store.GetInfraBackupMeta(customerID)
|
||||
|
||||
type detailData struct {
|
||||
Customer *store.CustomerSummary
|
||||
Report map[string]interface{}
|
||||
@@ -198,6 +201,8 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request, cu
|
||||
OverallStatus string
|
||||
NotifPrefs *store.NotificationPrefs
|
||||
RecentNotifications []store.NotificationLogEntry
|
||||
InfraBackup *store.InfraBackupMeta
|
||||
InfraBackupAge string
|
||||
}
|
||||
|
||||
overallStatus := "ok"
|
||||
@@ -211,6 +216,11 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request, cu
|
||||
overallStatus = "down"
|
||||
}
|
||||
|
||||
var infraBackupAge string
|
||||
if infraMeta != nil {
|
||||
infraBackupAge = timeAgo(infraMeta.UpdatedAt)
|
||||
}
|
||||
|
||||
data := detailData{
|
||||
Customer: customer,
|
||||
Report: report,
|
||||
@@ -218,6 +228,8 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request, cu
|
||||
OverallStatus: overallStatus,
|
||||
NotifPrefs: notifPrefs,
|
||||
RecentNotifications: recentNotifs,
|
||||
InfraBackup: infraMeta,
|
||||
InfraBackupAge: infraBackupAge,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
@@ -127,6 +127,29 @@
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<!-- Infra Backup (Disaster Recovery) -->
|
||||
<section class="card">
|
||||
<h2>Infra Backup</h2>
|
||||
{{if .InfraBackup}}
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="label">Last Updated</span>
|
||||
<span class="value">{{.InfraBackupAge}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Deployed Stacks</span>
|
||||
<span class="value">{{.InfraBackup.StackCount}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Disks</span>
|
||||
<span class="value">{{.InfraBackup.DiskCount}}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<p style="color: #facc15">No infra backup received yet</p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<!-- Health -->
|
||||
<section class="card">
|
||||
<h2>Health</h2>
|
||||
|
||||
Reference in New Issue
Block a user