package server import ( "context" "encoding/json" "fmt" "net/http" "strings" "scm.bstein.dev/bstein/soteria/internal/api" "scm.bstein.dev/bstein/soteria/internal/k8s" ) func (s *Server) handleInventory(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } inventory, err := s.buildInventory(r.Context()) if err != nil { writeError(w, http.StatusBadGateway, err.Error()) return } writeJSON(w, http.StatusOK, inventory) } func (s *Server) handleBackups(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } namespace := strings.TrimSpace(r.URL.Query().Get("namespace")) pvcName := strings.TrimSpace(r.URL.Query().Get("pvc")) if namespace == "" || pvcName == "" { writeError(w, http.StatusBadRequest, "namespace and pvc are required") return } volumeName, _, _, err := s.client.ResolvePVCVolume(r.Context(), namespace, pvcName) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } switch s.cfg.BackupDriver { case "longhorn": backups, err := s.longhorn.ListBackups(r.Context(), volumeName) if err != nil { writeError(w, http.StatusBadGateway, err.Error()) return } writeJSON(w, http.StatusOK, api.BackupListResponse{ Namespace: namespace, PVC: pvcName, Volume: volumeName, Backups: buildBackupRecords(backups), }) case "restic": jobs, err := s.client.ListBackupJobsForPVC(r.Context(), namespace, pvcName) if err != nil { writeError(w, http.StatusBadGateway, err.Error()) return } records := s.buildResticBackupRecords(r.Context(), namespace, jobs, s.cfg.ResticRepository) writeJSON(w, http.StatusOK, api.BackupListResponse{ Namespace: namespace, PVC: pvcName, Volume: volumeName, Backups: records, }) default: writeError(w, http.StatusBadRequest, "unsupported backup driver") } } func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } r.Body = http.MaxBytesReader(w, r.Body, 1<<20) var req api.BackupRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { s.metrics.RecordBackupRequest(s.cfg.BackupDriver, "invalid_json") writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid JSON: %v", err)) return } if strings.TrimSpace(req.Namespace) == "" || strings.TrimSpace(req.PVC) == "" { s.metrics.RecordBackupRequest(s.cfg.BackupDriver, "validation_error") writeError(w, http.StatusBadRequest, "namespace and pvc are required") return } if err := validateKeepLast(req.KeepLast); err != nil { s.metrics.RecordBackupRequest(s.cfg.BackupDriver, "validation_error") writeError(w, http.StatusBadRequest, err.Error()) return } req.Namespace = strings.TrimSpace(req.Namespace) req.PVC = strings.TrimSpace(req.PVC) requester := currentRequester(r.Context()) response, result, err := s.executeBackup(r.Context(), req, requester) s.metrics.RecordBackupRequest(s.cfg.BackupDriver, result) if err != nil { writeError(w, backupStatusCode(result), err.Error()) return } writeJSON(w, http.StatusOK, response) } func (s *Server) handleNamespaceBackup(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } r.Body = http.MaxBytesReader(w, r.Body, 1<<20) var req api.NamespaceBackupRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { s.metrics.RecordNamespaceBackupRequest(s.cfg.BackupDriver, "invalid_json") writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid JSON: %v", err)) return } req.Namespace = strings.TrimSpace(req.Namespace) if req.Namespace == "" { s.metrics.RecordNamespaceBackupRequest(s.cfg.BackupDriver, "validation_error") writeError(w, http.StatusBadRequest, "namespace is required") return } if err := validateKeepLast(req.KeepLast); err != nil { s.metrics.RecordNamespaceBackupRequest(s.cfg.BackupDriver, "validation_error") writeError(w, http.StatusBadRequest, err.Error()) return } if err := validateKubernetesName("namespace", req.Namespace); err != nil { s.metrics.RecordNamespaceBackupRequest(s.cfg.BackupDriver, "validation_error") writeError(w, http.StatusBadRequest, err.Error()) return } pvcs, err := s.listNamespaceBoundPVCs(r.Context(), req.Namespace) if err != nil { s.metrics.RecordNamespaceBackupRequest(s.cfg.BackupDriver, "backend_error") writeError(w, http.StatusBadGateway, err.Error()) return } requester := currentRequester(r.Context()) resolvedDedupe := dedupeDefault(req.Dedupe) resolvedKeepLast := keepLastDefault(req.KeepLast) response := api.NamespaceBackupResponse{ Namespace: req.Namespace, RequestedBy: requester, Driver: s.cfg.BackupDriver, DryRun: req.DryRun, Dedupe: resolvedDedupe, KeepLast: resolvedKeepLast, Results: make([]api.NamespaceBackupResult, 0, len(pvcs)), } for _, pvc := range pvcs { backupReq := api.BackupRequest{ Namespace: req.Namespace, PVC: pvc.Name, DryRun: req.DryRun, Dedupe: boolPtr(resolvedDedupe), KeepLast: intPtr(resolvedKeepLast), } result, status, execErr := s.executeBackup(r.Context(), backupReq, requester) s.metrics.RecordBackupRequest(s.cfg.BackupDriver, status) item := api.NamespaceBackupResult{ Namespace: req.Namespace, PVC: pvc.Name, Status: status, Volume: result.Volume, Backup: result.Backup, } if execErr != nil { item.Error = execErr.Error() response.Failed++ } else { response.Succeeded++ } response.Results = append(response.Results, item) } response.Total = len(response.Results) s.metrics.RecordNamespaceBackupRequest(s.cfg.BackupDriver, namespaceResultStatus(req.DryRun, response.Total, response.Succeeded, response.Failed)) writeJSON(w, http.StatusOK, response) } func (s *Server) executeBackup(ctx context.Context, req api.BackupRequest, requester string) (api.BackupResponse, string, error) { req.Namespace = strings.TrimSpace(req.Namespace) req.PVC = strings.TrimSpace(req.PVC) if req.Namespace == "" || req.PVC == "" { return api.BackupResponse{}, "validation_error", fmt.Errorf("namespace and pvc are required") } resolvedDedupe := dedupeDefault(req.Dedupe) resolvedKeepLast := keepLastDefault(req.KeepLast) req.Dedupe = boolPtr(resolvedDedupe) req.KeepLast = intPtr(resolvedKeepLast) switch s.cfg.BackupDriver { case "longhorn": volumeName, _, _, err := s.client.ResolvePVCVolume(ctx, req.Namespace, req.PVC) if err != nil { return api.BackupResponse{}, "validation_error", err } backupID := backupName("backup", req.Namespace+"-"+req.PVC) response := api.BackupResponse{ Driver: "longhorn", Volume: volumeName, Backup: backupID, Namespace: req.Namespace, RequestedBy: requester, DryRun: req.DryRun, Dedupe: resolvedDedupe, KeepLast: resolvedKeepLast, } if req.DryRun { return response, "dry_run", nil } labels := map[string]string{ "soteria.bstein.dev/namespace": req.Namespace, "soteria.bstein.dev/pvc": req.PVC, "soteria.bstein.dev/requested-by": requester, } if err := s.longhorn.CreateSnapshot(ctx, volumeName, backupID, labels); err != nil { return api.BackupResponse{}, "backend_error", err } if _, err := s.longhorn.SnapshotBackup(ctx, volumeName, backupID, labels, s.cfg.LonghornBackupMode); err != nil { return api.BackupResponse{}, "backend_error", err } return response, "success", nil case "restic": jobName, secretName, err := s.client.CreateBackupJob(ctx, s.cfg, req) if err != nil { return api.BackupResponse{}, "backend_error", err } result := "success" if req.DryRun { result = "dry_run" } return api.BackupResponse{ Driver: "restic", JobName: jobName, Namespace: req.Namespace, Secret: secretName, RequestedBy: requester, DryRun: req.DryRun, Dedupe: resolvedDedupe, KeepLast: resolvedKeepLast, }, result, nil default: return api.BackupResponse{}, "unsupported_driver", fmt.Errorf("unsupported backup driver") } } func (s *Server) listNamespaceBoundPVCs(ctx context.Context, namespace string) ([]k8s.PVCSummary, error) { items, err := s.client.ListBoundPVCs(ctx) if err != nil { return nil, err } filtered := make([]k8s.PVCSummary, 0, len(items)) for _, item := range items { if item.Namespace == namespace { filtered = append(filtered, item) } } return filtered, nil } func backupStatusCode(result string) int { switch result { case "validation_error", "unsupported_driver": return http.StatusBadRequest case "backend_error": return http.StatusBadGateway default: return http.StatusInternalServerError } } func namespaceResultStatus(dryRun bool, total, succeeded, failed int) string { if dryRun { return "dry_run" } if total == 0 { return "empty" } if failed == 0 { return "success" } if succeeded == 0 { return "failed" } return "partial" }