314 lines
10 KiB
Go
314 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], "-")
|
|
}
|
|
return name
|
|
}
|