package k8s import ( "strings" "testing" "time" "scm.bstein.dev/bstein/soteria/internal/api" "scm.bstein.dev/bstein/soteria/internal/config" batchv1 "k8s.io/api/batch/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func TestJobNameSanitizesAndTruncates(t *testing.T) { name := jobName("backup", "PVC.With Spaces_and_symbols________________________________") if len(name) > 63 { t.Fatalf("expected kubernetes-safe name length, got %d for %q", len(name), name) } if strings.ContainsAny(name, " _.") { t.Fatalf("expected sanitized name, got %q", name) } if !strings.HasPrefix(name, "soteria-backup-") { t.Fatalf("expected soteria backup prefix, got %q", name) } } func TestHelperDefaultsAndParsers(t *testing.T) { if !dedupeEnabled(nil) { t.Fatalf("expected nil dedupe to default true") } falseValue := false if dedupeEnabled(&falseValue) { t.Fatalf("expected explicit false dedupe to remain false") } if keepLastWithDefault(nil) != 0 { t.Fatalf("expected nil keep_last to default to zero") } negative := -5 if keepLastWithDefault(&negative) != 0 { t.Fatalf("expected negative keep_last to clamp to zero") } if !parseBoolWithDefault("yes", false) || parseBoolWithDefault("off", true) { t.Fatalf("unexpected bool parsing") } if parseIntWithDefault("12", 0) != 12 || parseIntWithDefault("-1", 7) != 7 || parseIntWithDefault("bad", 9) != 9 { t.Fatalf("unexpected int parsing") } } func TestRepositoryHelpers(t *testing.T) { base := "s3:https://b2.example.invalid/atlas" if repo := resticRepositoryForBackup(base, "apps", "data", true); repo != base { t.Fatalf("expected dedupe-enabled repository to stay shared, got %q", repo) } isolated := resticRepositoryForBackup(base, "Apps", "Data.Volume", false) if !strings.Contains(isolated, "/isolated/apps/data-volume") { t.Fatalf("expected isolated repository suffix, got %q", isolated) } if sanitizeRepositorySegment(" ") != "unknown" { t.Fatalf("expected blank repository segment to default to unknown") } if appended := appendRepositoryPath("https://b2.example.invalid/root/", "/child/"); appended != "https://b2.example.invalid/root/child" { t.Fatalf("unexpected appended repository path %q", appended) } } func TestBuildBackupJobIncludesResticMetadata(t *testing.T) { cfg := &config.Config{ ResticImage: "restic/restic:test", ResticRepository: "s3:https://b2.example.invalid/atlas", ResticBackupArgs: []string{"--exclude", "/tmp"}, ResticForgetArgs: []string{"--keep-last", "7"}, S3Endpoint: "https://b2.example.invalid", S3Region: "us-west-000", JobTTLSeconds: 900, JobNodeSelector: map[string]string{"hardware": "rpi5"}, WorkerServiceAccount: "soteria-worker", } req := api.BackupRequest{ Namespace: "apps", PVC: "data", Tags: []string{"manual", "drill"}, } job := buildBackupJob(cfg, req, "backup-job", "restic-secret", cfg.ResticRepository, true, 3) if job.Name != "backup-job" || job.Namespace != "apps" { t.Fatalf("unexpected job metadata: %#v", job.ObjectMeta) } if job.Labels[labelPVC] != "data" || job.Annotations[annotationKeepLast] != "3" { t.Fatalf("unexpected backup annotations/labels: %#v %#v", job.Labels, job.Annotations) } container := job.Spec.Template.Spec.Containers[0] if container.Image != "restic/restic:test" || container.Args[0] == "" { t.Fatalf("unexpected restic container: %#v", container) } if !strings.Contains(container.Args[0], "--tag pvc=data") || !strings.Contains(container.Args[0], "--exclude /tmp") { t.Fatalf("expected generated backup command to include request metadata, got %q", container.Args[0]) } if job.Spec.Template.Spec.ServiceAccountName != "soteria-worker" { t.Fatalf("expected worker service account, got %#v", job.Spec.Template.Spec.ServiceAccountName) } if job.Spec.Template.Spec.NodeSelector["hardware"] != "rpi5" { t.Fatalf("expected node selector to be copied, got %#v", job.Spec.Template.Spec.NodeSelector) } } func TestBuildRestoreJobUsesTargetPVC(t *testing.T) { cfg := &config.Config{ ResticImage: "restic/restic:test", ResticRepository: "s3:https://b2.example.invalid/atlas", JobTTLSeconds: 600, } req := api.RestoreTestRequest{ Namespace: "apps", TargetPVC: "restore-data", } job := buildRestoreJob(cfg, req, "restore-job", "restore-secret", "latest", cfg.ResticRepository) if job.Labels[labelPVC] != "restore-data" { t.Fatalf("expected target pvc label, got %#v", job.Labels) } if claim := job.Spec.Template.Spec.Volumes[0].PersistentVolumeClaim; claim == nil || claim.ClaimName != "restore-data" { t.Fatalf("expected restore volume pvc claim, got %#v", job.Spec.Template.Spec.Volumes[0]) } } func TestGeneratedResticCommandsAndEnv(t *testing.T) { cfg := &config.Config{ ResticRepository: "s3:https://b2.example.invalid/atlas", ResticBackupArgs: []string{"--exclude", "/cache"}, S3Endpoint: "https://b2.example.invalid", S3Region: "us-west-000", } keepLast := 2 command := backupCommand(cfg, api.BackupRequest{ PVC: "data", Dedupe: boolPtr(false), KeepLast: &keepLast, Tags: []string{"nightly"}, }) if !strings.Contains(command, "restic init") || !strings.Contains(command, "--keep-last 2") || !strings.Contains(command, "dedupe=off") { t.Fatalf("unexpected backup command %q", command) } restore := restoreCommand("latest") if !strings.Contains(restore, "restic restore latest") || !strings.Contains(restore, "/restore/") { t.Fatalf("unexpected restore command %q", restore) } env := resticEnv(cfg, "restic-secret", "") values := map[string]string{} for _, item := range env { values[item.Name] = item.Value } if values["RESTIC_REPOSITORY"] != cfg.ResticRepository || values["AWS_REGION"] != "us-west-000" { t.Fatalf("unexpected env values %#v", values) } if int32Ptr(7) == nil || *int32Ptr(7) != 7 { t.Fatalf("expected int32 pointer helper to round-trip") } } func TestSummarizeAndSortBackupJobs(t *testing.T) { now := time.Now().UTC() completed := batchv1.Job{ ObjectMeta: metav1.ObjectMeta{ Name: "job-complete", Namespace: "apps", CreationTimestamp: metav1.NewTime(now.Add(-2 * time.Hour)), Annotations: map[string]string{ annotationResticRepository: "s3:https://b2.example.invalid/atlas", annotationDedupeEnabled: "false", annotationKeepLast: "5", }, }, Status: batchv1.JobStatus{ Succeeded: 1, CompletionTime: &metav1.Time{Time: now.Add(-time.Hour)}, }, } running := batchv1.Job{ ObjectMeta: metav1.ObjectMeta{ Name: "job-running", Namespace: "apps", CreationTimestamp: metav1.NewTime(now.Add(-30 * time.Minute)), }, Status: batchv1.JobStatus{ Active: 1, }, } completedSummary := summarizeBackupJob(completed, "data") if completedSummary.State != "Completed" || completedSummary.DedupeEnabled || completedSummary.KeepLast != 5 { t.Fatalf("unexpected completed summary %#v", completedSummary) } runningSummary := summarizeBackupJob(running, "data") if runningSummary.State != "Running" { t.Fatalf("unexpected running summary %#v", runningSummary) } items := []BackupJobSummary{completedSummary, runningSummary} sortBackupJobSummaries(items) if items[0].Name != "job-running" { t.Fatalf("expected newer running job first, got %#v", items) } } func boolPtr(value bool) *bool { return &value }