soteria/internal/server/restore_handlers.go

317 lines
10 KiB
Go

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], "-")
}
if name == "" {
name = "restore"
}
return name
}