package server import ( "encoding/json" "fmt" "net/http" "strings" "time" "scm.bstein.dev/bstein/soteria/internal/api" "scm.bstein.dev/bstein/soteria/internal/config" "scm.bstein.dev/bstein/soteria/internal/k8s" "scm.bstein.dev/bstein/soteria/internal/longhorn" ) type Server struct { cfg *config.Config client *k8s.Client longhorn *longhorn.Client mux *http.ServeMux } func New(cfg *config.Config, client *k8s.Client, lh *longhorn.Client) *Server { s := &Server{ cfg: cfg, client: client, longhorn: lh, mux: http.NewServeMux(), } s.mux.HandleFunc("/healthz", s.handleHealth) s.mux.HandleFunc("/readyz", s.handleReady) s.mux.HandleFunc("/v1/backup", s.handleBackup) s.mux.HandleFunc("/v1/restore-test", s.handleRestore) return s } func (s *Server) Handler() http.Handler { return s.mux } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) } 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 { writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid JSON: %v", err)) return } switch s.cfg.BackupDriver { case "longhorn": volumeName, _, _, err := s.client.ResolvePVCVolume(r.Context(), req.Namespace, req.PVC) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } backupName := backupName("backup", req.PVC) if req.DryRun { writeJSON(w, http.StatusOK, api.BackupResponse{ Driver: "longhorn", Volume: volumeName, Backup: backupName, Namespace: req.Namespace, DryRun: true, }) return } labels := map[string]string{ "soteria.bstein.dev/namespace": req.Namespace, "soteria.bstein.dev/pvc": req.PVC, } if _, err := s.longhorn.SnapshotBackup(r.Context(), volumeName, backupName, labels, s.cfg.LonghornBackupMode); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, api.BackupResponse{ Driver: "longhorn", Volume: volumeName, Backup: backupName, Namespace: req.Namespace, DryRun: false, }) case "restic": jobName, secretName, err := s.client.CreateBackupJob(r.Context(), s.cfg, req) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, api.BackupResponse{ Driver: "restic", JobName: jobName, Namespace: req.Namespace, Secret: secretName, DryRun: req.DryRun, }) default: writeError(w, http.StatusBadRequest, "unsupported backup driver") } } 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 { writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid JSON: %v", err)) return } switch s.cfg.BackupDriver { case "longhorn": if req.TargetPVC == "" { writeError(w, http.StatusBadRequest, "target_pvc is required") return } if req.PVC == "" { writeError(w, http.StatusBadRequest, "pvc is required to locate backup volume") return } volumeName, _, _, err := s.client.ResolvePVCVolume(r.Context(), req.Namespace, req.PVC) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } backupURL := strings.TrimSpace(req.BackupURL) if backupURL == "" { backup, err := s.longhorn.FindBackup(r.Context(), volumeName, req.Snapshot) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } backupURL = backup.URL } if backupURL == "" { writeError(w, http.StatusBadRequest, "backup_url is required") return } restoreVolumeName := backupName("restore", req.TargetPVC) if req.DryRun { writeJSON(w, http.StatusOK, api.RestoreTestResponse{ Driver: "longhorn", Volume: restoreVolumeName, TargetPVC: req.TargetPVC, BackupURL: backupURL, Namespace: req.Namespace, DryRun: true, }) return } sourceVolume, err := s.longhorn.GetVolume(r.Context(), volumeName) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } replicas := sourceVolume.NumberOfReplicas if replicas == 0 { replicas = 2 } if _, err := s.longhorn.CreateVolumeFromBackup(r.Context(), restoreVolumeName, sourceVolume.Size, replicas, backupURL); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if err := s.longhorn.CreatePVC(r.Context(), restoreVolumeName, req.Namespace, req.TargetPVC); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, api.RestoreTestResponse{ Driver: "longhorn", Volume: restoreVolumeName, TargetPVC: req.TargetPVC, BackupURL: backupURL, Namespace: req.Namespace, DryRun: false, }) case "restic": jobName, secretName, err := s.client.CreateRestoreJob(r.Context(), s.cfg, req) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, api.RestoreTestResponse{ Driver: "restic", JobName: jobName, Namespace: req.Namespace, Secret: secretName, DryRun: req.DryRun, }) default: writeError(w, http.StatusBadRequest, "unsupported backup driver") } } func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(payload) } func writeError(w http.ResponseWriter, status int, message string) { writeJSON(w, status, map[string]string{"error": message}) } func backupName(prefix, value string) string { base := sanitizeName(fmt.Sprintf("soteria-%s-%s", prefix, value)) timestamp := time.Now().UTC().Format("20060102-150405") name := fmt.Sprintf("%s-%s", base, timestamp) if len(name) <= 63 { return name } maxBase := 63 - len(timestamp) - 1 if maxBase < 1 { maxBase = 1 } if len(base) > maxBase { base = base[:maxBase] } return fmt.Sprintf("%s-%s", base, timestamp) } func sanitizeName(value string) string { value = strings.ToLower(value) value = strings.ReplaceAll(value, "_", "-") value = strings.ReplaceAll(value, ".", "-") value = strings.ReplaceAll(value, " ", "-") value = strings.Trim(value, "-") return value }