214 lines
7.3 KiB
Go
214 lines
7.3 KiB
Go
|
|
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
|
||
|
|
}
|