diff --git a/internal/k8s/jobs_test.go b/internal/k8s/jobs_test.go new file mode 100644 index 0000000..11815e0 --- /dev/null +++ b/internal/k8s/jobs_test.go @@ -0,0 +1,213 @@ +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 +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 016dbf3..059364d 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -321,6 +321,179 @@ func TestStartSeedsInitialBackgroundState(t *testing.T) { } } +func TestQueryBoolParsesTruthyValues(t *testing.T) { + if !queryBool("true") || !queryBool(" yes ") || !queryBool("1") || !queryBool("ON") { + t.Fatalf("expected truthy values to parse as true") + } + if queryBool("false") || queryBool("") || queryBool("0") { + t.Fatalf("expected falsey values to parse as false") + } +} + +func TestHandleB2UsageRejectsUnsupportedMethod(t *testing.T) { + srv := &Server{cfg: &config.Config{}, metrics: newTelemetry()} + srv.handler = http.HandlerFunc(srv.route) + + req := httptest.NewRequest(http.MethodPost, "/v1/b2", nil) + res := httptest.NewRecorder() + srv.Handler().ServeHTTP(res, req) + + if res.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d: %s", res.Code, res.Body.String()) + } +} + +func TestHandleB2UsageReturnsCachedSnapshot(t *testing.T) { + srv := &Server{ + cfg: &config.Config{B2Enabled: false}, + metrics: newTelemetry(), + b2Usage: api.B2UsageResponse{Enabled: false, Endpoint: "cached-endpoint"}, + } + srv.handler = http.HandlerFunc(srv.route) + + req := httptest.NewRequest(http.MethodGet, "/v1/b2", nil) + res := httptest.NewRecorder() + srv.Handler().ServeHTTP(res, req) + + if res.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", res.Code, res.Body.String()) + } + + var payload api.B2UsageResponse + if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode b2 response: %v", err) + } + if payload.Endpoint != "cached-endpoint" { + t.Fatalf("expected cached snapshot, got %#v", payload) + } +} + +func TestRefreshB2UsageDisabledStoresDisabledSnapshot(t *testing.T) { + srv := &Server{ + cfg: &config.Config{B2Enabled: false}, + metrics: newTelemetry(), + } + + srv.refreshB2Usage(context.Background()) + + if usage := srv.getB2Usage(); usage.Enabled { + t.Fatalf("expected disabled usage snapshot, got %#v", usage) + } +} + +func TestResolveB2CredentialsFromConfigValues(t *testing.T) { + srv := &Server{ + cfg: &config.Config{ + B2Endpoint: "https://s3.us-west-000.backblazeb2.com", + B2Region: "us-west-000", + B2AccessKeyID: "abc", + B2SecretAccessKey: "def", + }, + client: &fakeKubeClient{}, + } + + creds, err := srv.resolveB2Credentials(context.Background()) + if err != nil { + t.Fatalf("resolve b2 credentials: %v", err) + } + if creds.Endpoint != "https://s3.us-west-000.backblazeb2.com" || creds.Region != "us-west-000" { + t.Fatalf("unexpected endpoint/region: %#v", creds) + } + if creds.AccessKeyID != "abc" || creds.SecretAccessKey != "def" { + t.Fatalf("unexpected keys: %#v", creds) + } +} + +func TestResolveB2CredentialsLoadsSecretValues(t *testing.T) { + kube := &fakeKubeClient{ + secretData: map[string][]byte{ + "AWS_ACCESS_KEY_ID": []byte("abc"), + "AWS_SECRET_ACCESS_KEY": []byte("def"), + "AWS_ENDPOINT": []byte("https://s3.us-west-123.backblazeb2.com"), + }, + } + srv := &Server{ + cfg: &config.Config{ + Namespace: "atlas", + B2SecretNamespace: "atlas", + B2SecretName: "b2-creds", + B2AccessKeyField: "AWS_ACCESS_KEY_ID", + B2SecretKeyField: "AWS_SECRET_ACCESS_KEY", + B2EndpointField: "AWS_ENDPOINT", + }, + client: kube, + } + + creds, err := srv.resolveB2Credentials(context.Background()) + if err != nil { + t.Fatalf("resolve b2 credentials from secret: %v", err) + } + if creds.Region != "us-west-123" { + t.Fatalf("expected inferred region, got %#v", creds) + } + if creds.AccessKeyID != "abc" || creds.SecretAccessKey != "def" { + t.Fatalf("unexpected secret credentials: %#v", creds) + } +} + +func TestResolveB2CredentialsRejectsMissingValues(t *testing.T) { + srv := &Server{ + cfg: &config.Config{}, + client: &fakeKubeClient{}, + } + + if _, err := srv.resolveB2Credentials(context.Background()); err == nil || !strings.Contains(err.Error(), "B2 endpoint is not configured") { + t.Fatalf("expected missing endpoint error, got %v", err) + } +} + +func TestLoadB2SecretValueValidatesInputs(t *testing.T) { + srv := &Server{ + cfg: &config.Config{ + B2SecretNamespace: "atlas", + B2SecretName: "b2-creds", + }, + client: &fakeKubeClient{}, + } + + if _, err := srv.loadB2SecretValue(context.Background(), " "); err == nil || !strings.Contains(err.Error(), "empty") { + t.Fatalf("expected empty key error, got %v", err) + } + if _, err := srv.loadB2SecretValue(context.Background(), "AWS_ACCESS_KEY_ID"); err == nil || !strings.Contains(err.Error(), "is empty") { + t.Fatalf("expected empty secret value error, got %v", err) + } +} + +func TestNormalizeS3Endpoint(t *testing.T) { + host, secure, err := normalizeS3Endpoint("https://s3.us-west-000.backblazeb2.com") + if err != nil || host != "s3.us-west-000.backblazeb2.com" || !secure { + t.Fatalf("unexpected https endpoint parse: %q %v %v", host, secure, err) + } + host, secure, err = normalizeS3Endpoint("http://minio.internal:9000") + if err != nil || host != "minio.internal:9000" || secure { + t.Fatalf("unexpected http endpoint parse: %q %v %v", host, secure, err) + } + host, secure, err = normalizeS3Endpoint("minio.internal:9000/") + if err != nil || host != "minio.internal:9000" || !secure { + t.Fatalf("unexpected bare endpoint parse: %q %v %v", host, secure, err) + } + if _, _, err := normalizeS3Endpoint(""); err == nil { + t.Fatalf("expected empty endpoint error") + } + if _, _, err := normalizeS3Endpoint("https://"); err == nil { + t.Fatalf("expected missing host error") + } +} + +func TestInferB2RegionFallsBackWhenNeeded(t *testing.T) { + if region := inferB2Region("https://s3.us-west-009.backblazeb2.com", "fallback"); region != "us-west-009" { + t.Fatalf("expected inferred region, got %q", region) + } + if region := inferB2Region("http://minio.internal:9000", "fallback"); region != "fallback" { + t.Fatalf("expected fallback region, got %q", region) + } +} + func TestProtectedInventoryRequiresAuth(t *testing.T) { srv := &Server{ cfg: &config.Config{AuthRequired: true, AllowedGroups: []string{"admin", "maintenance"}, BackupDriver: "longhorn"},