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
+69
View File
@@ -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{}{