soteria/internal/server/backup_handlers.go

304 lines
9.0 KiB
Go

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"
}