package server import ( "context" "encoding/json" "fmt" "log" "net/http" "strings" "scm.bstein.dev/bstein/soteria/internal/api" ) func (s *Server) handleRestore(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.RestoreTestRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { s.metrics.RecordRestoreRequest(s.cfg.BackupDriver, "invalid_json") writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid JSON: %v", err)) return } req.Namespace = strings.TrimSpace(req.Namespace) req.PVC = strings.TrimSpace(req.PVC) req.TargetPVC = strings.TrimSpace(req.TargetPVC) req.TargetNamespace = strings.TrimSpace(req.TargetNamespace) if req.Namespace == "" { s.metrics.RecordRestoreRequest(s.cfg.BackupDriver, "validation_error") writeError(w, http.StatusBadRequest, "namespace is required") return } if req.PVC == "" { s.metrics.RecordRestoreRequest(s.cfg.BackupDriver, "validation_error") writeError(w, http.StatusBadRequest, "pvc is required") return } if req.TargetPVC == "" { s.metrics.RecordRestoreRequest(s.cfg.BackupDriver, "validation_error") writeError(w, http.StatusBadRequest, "target_pvc is required") return } if req.TargetNamespace == "" { req.TargetNamespace = req.Namespace } if err := validateKubernetesName("namespace", req.Namespace); err != nil { s.metrics.RecordRestoreRequest(s.cfg.BackupDriver, "validation_error") writeError(w, http.StatusBadRequest, err.Error()) return } if err := validateKubernetesName("pvc", req.PVC); err != nil { s.metrics.RecordRestoreRequest(s.cfg.BackupDriver, "validation_error") writeError(w, http.StatusBadRequest, err.Error()) return } if err := validateKubernetesName("target_namespace", req.TargetNamespace); err != nil { s.metrics.RecordRestoreRequest(s.cfg.BackupDriver, "validation_error") writeError(w, http.StatusBadRequest, err.Error()) return } if err := validateKubernetesName("target_pvc", req.TargetPVC); err != nil { s.metrics.RecordRestoreRequest(s.cfg.BackupDriver, "validation_error") writeError(w, http.StatusBadRequest, err.Error()) return } if req.Namespace == req.TargetNamespace && req.PVC == req.TargetPVC { s.metrics.RecordRestoreRequest(s.cfg.BackupDriver, "conflict") writeError(w, http.StatusConflict, "target namespace/pvc must differ from source") return } requester := currentRequester(r.Context()) response, result, err := s.executeRestore(r.Context(), req, requester) s.metrics.RecordRestoreRequest(s.cfg.BackupDriver, result) if err != nil { writeError(w, restoreStatusCode(result), err.Error()) return } writeJSON(w, http.StatusOK, response) } func (s *Server) handleNamespaceRestore(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.NamespaceRestoreRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { s.metrics.RecordNamespaceRestoreRequest(s.cfg.BackupDriver, "invalid_json") writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid JSON: %v", err)) return } req.Namespace = strings.TrimSpace(req.Namespace) req.TargetNamespace = strings.TrimSpace(req.TargetNamespace) req.TargetPrefix = strings.TrimSpace(req.TargetPrefix) req.Snapshot = strings.TrimSpace(req.Snapshot) if req.Namespace == "" { s.metrics.RecordNamespaceRestoreRequest(s.cfg.BackupDriver, "validation_error") writeError(w, http.StatusBadRequest, "namespace is required") return } if req.TargetNamespace == "" { req.TargetNamespace = req.Namespace } if err := validateKubernetesName("namespace", req.Namespace); err != nil { s.metrics.RecordNamespaceRestoreRequest(s.cfg.BackupDriver, "validation_error") writeError(w, http.StatusBadRequest, err.Error()) return } if err := validateKubernetesName("target_namespace", req.TargetNamespace); err != nil { s.metrics.RecordNamespaceRestoreRequest(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.RecordNamespaceRestoreRequest(s.cfg.BackupDriver, "backend_error") writeError(w, http.StatusBadGateway, err.Error()) return } requester := currentRequester(r.Context()) response := api.NamespaceRestoreResponse{ Namespace: req.Namespace, TargetNamespace: req.TargetNamespace, RequestedBy: requester, Driver: s.cfg.BackupDriver, DryRun: req.DryRun, Results: make([]api.NamespaceRestoreResult, 0, len(pvcs)), } for _, pvc := range pvcs { targetPVC := targetPVCName(req.TargetPrefix, pvc.Name) restoreReq := api.RestoreTestRequest{ Namespace: req.Namespace, PVC: pvc.Name, Snapshot: req.Snapshot, TargetNamespace: req.TargetNamespace, TargetPVC: targetPVC, DryRun: req.DryRun, } result, status, execErr := s.executeRestore(r.Context(), restoreReq, requester) s.metrics.RecordRestoreRequest(s.cfg.BackupDriver, status) item := api.NamespaceRestoreResult{ Namespace: req.Namespace, PVC: pvc.Name, TargetNamespace: req.TargetNamespace, TargetPVC: targetPVC, Status: status, Volume: result.Volume, BackupURL: result.BackupURL, } 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.RecordNamespaceRestoreRequest(s.cfg.BackupDriver, namespaceResultStatus(req.DryRun, response.Total, response.Succeeded, response.Failed)) writeJSON(w, http.StatusOK, response) } func (s *Server) executeRestore(ctx context.Context, req api.RestoreTestRequest, requester string) (api.RestoreTestResponse, string, error) { req.Namespace = strings.TrimSpace(req.Namespace) req.PVC = strings.TrimSpace(req.PVC) req.TargetNamespace = strings.TrimSpace(req.TargetNamespace) req.TargetPVC = strings.TrimSpace(req.TargetPVC) req.BackupURL = strings.TrimSpace(req.BackupURL) req.Snapshot = strings.TrimSpace(req.Snapshot) if req.TargetNamespace == "" { req.TargetNamespace = req.Namespace } switch s.cfg.BackupDriver { case "longhorn": exists, err := s.client.PersistentVolumeClaimExists(ctx, req.TargetNamespace, req.TargetPVC) if err != nil { return api.RestoreTestResponse{}, "validation_error", err } if exists { return api.RestoreTestResponse{}, "conflict", fmt.Errorf("target pvc %s/%s already exists", req.TargetNamespace, req.TargetPVC) } volumeName, _, _, err := s.client.ResolvePVCVolume(ctx, req.Namespace, req.PVC) if err != nil { return api.RestoreTestResponse{}, "validation_error", err } backupURL := req.BackupURL if backupURL == "" { backup, err := s.longhorn.FindBackup(ctx, volumeName, req.Snapshot) if err != nil { return api.RestoreTestResponse{}, "validation_error", err } backupURL = strings.TrimSpace(backup.URL) } if backupURL == "" { return api.RestoreTestResponse{}, "validation_error", fmt.Errorf("backup_url is required") } restoreVolumeName := backupName("restore", req.TargetNamespace+"-"+req.TargetPVC) response := api.RestoreTestResponse{ Driver: "longhorn", Volume: restoreVolumeName, TargetNamespace: req.TargetNamespace, TargetPVC: req.TargetPVC, BackupURL: backupURL, Namespace: req.Namespace, RequestedBy: requester, DryRun: req.DryRun, } if req.DryRun { return response, "dry_run", nil } sourceVolume, err := s.longhorn.GetVolume(ctx, volumeName) if err != nil { return api.RestoreTestResponse{}, "backend_error", err } replicas := sourceVolume.NumberOfReplicas if replicas == 0 { replicas = 2 } if _, err := s.longhorn.CreateVolumeFromBackup(ctx, restoreVolumeName, sourceVolume.Size, replicas, backupURL); err != nil { return api.RestoreTestResponse{}, "backend_error", err } if err := s.longhorn.CreatePVC(ctx, restoreVolumeName, req.TargetNamespace, req.TargetPVC); err != nil { cleanupErr := s.longhorn.DeleteVolume(ctx, restoreVolumeName) if cleanupErr != nil { log.Printf("restore cleanup failed for %s: %v", restoreVolumeName, cleanupErr) return api.RestoreTestResponse{}, "backend_error", fmt.Errorf("create restore pvc: %v (cleanup failed: %v)", err, cleanupErr) } return api.RestoreTestResponse{}, "backend_error", fmt.Errorf("create restore pvc: %v", err) } return response, "success", nil case "restic": if repo, snapshot, ok := decodeResticSelector(req.BackupURL); ok { if strings.TrimSpace(req.Snapshot) == "" { req.Snapshot = snapshot } if strings.TrimSpace(req.Repository) == "" { req.Repository = repo } } jobName, secretName, err := s.client.CreateRestoreJob(ctx, s.cfg, req) if err != nil { return api.RestoreTestResponse{}, "backend_error", err } result := "success" if req.DryRun { result = "dry_run" } return api.RestoreTestResponse{ Driver: "restic", JobName: jobName, Namespace: req.Namespace, TargetNamespace: req.TargetNamespace, TargetPVC: req.TargetPVC, Secret: secretName, RequestedBy: requester, DryRun: req.DryRun, }, result, nil default: return api.RestoreTestResponse{}, "unsupported_driver", fmt.Errorf("unsupported backup driver") } } func restoreStatusCode(result string) int { switch result { case "validation_error", "unsupported_driver": return http.StatusBadRequest case "conflict": return http.StatusConflict case "backend_error": return http.StatusBadGateway default: return http.StatusInternalServerError } } func targetPVCName(prefix, sourcePVC string) string { prefix = sanitizeName(prefix) if prefix == "" { prefix = "restore" } if !strings.HasSuffix(prefix, "-") { prefix += "-" } name := sanitizeName(prefix + sourcePVC) if name == "" { name = "restore" } if len(name) > 63 { name = strings.Trim(name[:63], "-") } return name }