304 lines
9.0 KiB
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"
|
|
}
|