soteria/internal/server/server_utilities.go

337 lines
7.4 KiB
Go
Raw Normal View History

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