package server import ( "context" "encoding/base64" "encoding/json" "fmt" "math" "net/http" "sort" "strconv" "strings" "time" "scm.bstein.dev/bstein/soteria/internal/api" "scm.bstein.dev/bstein/soteria/internal/k8s" "scm.bstein.dev/bstein/soteria/internal/longhorn" "k8s.io/apimachinery/pkg/api/resource" k8svalidation "k8s.io/apimachinery/pkg/util/validation" ) func validateKubernetesName(field, value string) error { if errs := k8svalidation.IsDNS1123Label(value); len(errs) > 0 { return fmt.Errorf("%s must be a valid Kubernetes DNS-1123 label", field) } return nil } func buildBackupRecords(backups []longhorn.Backup) []api.BackupRecord { records := make([]api.BackupRecord, 0, len(backups)) latestName := "" if latest, _, ok := latestCompletedBackup(backups); ok { latestName = latest.Name } sort.Slice(backups, func(i, j int) bool { left, lok := parseBackupTime(backups[i].Created) right, rok := parseBackupTime(backups[j].Created) switch { case lok && rok: return left.After(right) case lok: return true case rok: return false default: return backups[i].Name > backups[j].Name } }) for _, backup := range backups { records = append(records, api.BackupRecord{ Name: backup.Name, SnapshotName: backup.SnapshotName, Created: backup.Created, State: backup.State, URL: backup.URL, Size: backup.Size, Latest: backup.Name == latestName, }) } return records } func (s *Server) buildResticBackupRecords(ctx context.Context, namespace string, jobs []k8s.BackupJobSummary, defaultRepository string) []api.BackupRecord { records := make([]api.BackupRecord, 0, len(jobs)) latestName := "" for _, job := range jobs { if strings.EqualFold(job.State, "Completed") { latestName = job.Name break } } for _, job := range jobs { created := "" if ts := backupJobTimestamp(job); !ts.IsZero() { created = ts.UTC().Format(time.RFC3339) } url := "" size := "" latest := job.Name == latestName if latest && strings.EqualFold(job.State, "Completed") { repository := strings.TrimSpace(job.Repository) if repository == "" { repository = strings.TrimSpace(defaultRepository) } url = encodeResticSelector(repository) } if strings.EqualFold(job.State, "Completed") { if bytes, ok := s.lookupResticStoredBytesForJob(ctx, namespace, job.Name); ok { size = formatBytesIEC(bytes) } } records = append(records, api.BackupRecord{ Name: job.Name, SnapshotName: job.Name, Created: created, State: job.State, URL: url, Size: size, Latest: latest, }) } return records } func encodeResticSelector(repository string) string { repository = strings.TrimSpace(repository) if repository == "" { return "latest" } return resticSelectorPrefix + base64.RawURLEncoding.EncodeToString([]byte(repository)) } func decodeResticSelector(raw string) (string, string, bool) { raw = strings.TrimSpace(raw) if raw == "" { return "", "", false } if raw == "latest" { return "", "latest", true } if !strings.HasPrefix(raw, resticSelectorPrefix) { return "", "", false } encoded := strings.TrimPrefix(raw, resticSelectorPrefix) if encoded == "" { return "", "", false } decoded, err := base64.RawURLEncoding.DecodeString(encoded) if err != nil { return "", "", false } repository := strings.TrimSpace(string(decoded)) if repository == "" { return "", "", false } return repository, "latest", true } func backupJobTimestamp(job k8s.BackupJobSummary) time.Time { if !job.CompletionTime.IsZero() { return job.CompletionTime } return job.CreatedAt } func backupJobInProgress(state string) bool { switch strings.ToLower(strings.TrimSpace(state)) { case "pending", "running": return true default: return false } } func backupJobProgressPct(state string) int { switch strings.ToLower(strings.TrimSpace(state)) { case "pending": return 20 case "running": return 70 case "completed", "failed": return 100 default: return 0 } } func latestCompletedBackup(backups []longhorn.Backup) (longhorn.Backup, time.Time, bool) { var selected longhorn.Backup var selectedTime time.Time found := false for _, backup := range backups { if backup.State != "Completed" { continue } createdAt, ok := parseBackupTime(backup.Created) if !ok { if !found { selected = backup found = true } continue } if !found || createdAt.After(selectedTime) { selected = backup selectedTime = createdAt found = true } } return selected, selectedTime, found } func parseBackupTime(raw string) (time.Time, bool) { layouts := []string{time.RFC3339Nano, time.RFC3339} for _, layout := range layouts { parsed, err := time.Parse(layout, raw) if err == nil { return parsed, true } } return time.Time{}, false } 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 } func roundHours(value float64) float64 { return math.Round(value*100) / 100 } func parseSizeBytes(raw string) int64 { raw = strings.TrimSpace(raw) if raw == "" { return 0 } if value, err := strconv.ParseInt(raw, 10, 64); err == nil { return value } if value, err := strconv.ParseFloat(raw, 64); err == nil { if value < 0 { return 0 } return int64(value) } if quantity, err := resource.ParseQuantity(raw); err == nil { return quantity.Value() } return 0 } func formatBytesIEC(value float64) string { if value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) { return "0 B" } units := []string{"B", "KiB", "MiB", "GiB", "TiB"} size := value unit := 0 for size >= 1024 && unit < len(units)-1 { size /= 1024 unit++ } if unit == 0 { return fmt.Sprintf("%.0f %s", size, units[unit]) } return fmt.Sprintf("%.2f %s", size, units[unit]) } func dedupeDefault(value *bool) bool { if value == nil { return true } return *value } func boolPtr(value bool) *bool { ptr := value return &ptr } func keepLastDefault(value *int) int { if value == nil { return 0 } if *value < 0 { return 0 } return *value } func intPtr(value int) *int { ptr := value return &ptr } func validateKeepLast(value *int) error { if value == nil { return nil } if *value < 0 { return fmt.Errorf("keep_last must be >= 0") } if *value > maxPolicyKeepLast { return fmt.Errorf("keep_last must be <= %d", maxPolicyKeepLast) } return nil } func keepLastStricter(candidate, current int) bool { switch { case candidate > 0 && current == 0: return true case candidate == 0: return false case current == 0: return true default: return candidate < current } }