337 lines
7.4 KiB
Go
337 lines
7.4 KiB
Go
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
|
|
}
|
|
}
|