soteria/internal/k8s/jobs_test.go

214 lines
7.3 KiB
Go
Raw Normal View History

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
}