diff --git a/cmd/soteria/main_test.go b/cmd/soteria/main_test.go index bb6ab77..d9fa55e 100644 --- a/cmd/soteria/main_test.go +++ b/cmd/soteria/main_test.go @@ -3,9 +3,11 @@ package main import ( "context" "errors" + "net" "net/http" "os" "testing" + "time" "scm.bstein.dev/bstein/soteria/internal/config" "scm.bstein.dev/bstein/soteria/internal/k8s" @@ -228,6 +230,63 @@ func TestRunLogsShutdownAndLateServerErrorsWithoutFailing(t *testing.T) { } } +func TestDefaultMainHooksExecute(t *testing.T) { + restore := swapMainTestHooks() + defer restore() + + cfg := &config.Config{ + ListenAddr: "127.0.0.1:0", + MetricsRefreshInterval: time.Hour, + PolicyEvalInterval: time.Hour, + BackupMaxAge: time.Hour, + } + + app := newServerFn(cfg, &k8s.Client{}, &longhorn.Client{}) + if app == nil || app.Handler() == nil { + t.Fatalf("expected default server hook to build an application server") + } + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserve local address: %v", err) + } + addr := ln.Addr().String() + _ = ln.Close() + + httpServer := &http.Server{ + Addr: addr, + Handler: http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), + } + + errCh := make(chan error, 1) + go func() { + errCh <- listenAndServeFn(httpServer) + }() + + deadline := time.Now().Add(2 * time.Second) + for { + conn, dialErr := net.DialTimeout("tcp", addr, 50*time.Millisecond) + if dialErr == nil { + _ = conn.Close() + break + } + if time.Now().After(deadline) { + t.Fatalf("default listen hook did not start server: %v", dialErr) + } + time.Sleep(10 * time.Millisecond) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := shutdownServerFn(httpServer, ctx); err != nil { + t.Fatalf("expected default shutdown hook to succeed, got %v", err) + } + + if err := <-errCh; err != nil && !errors.Is(err, http.ErrServerClosed) { + t.Fatalf("expected server closed or nil from default hooks, got %v", err) + } +} + func swapMainTestHooks() func() { originalLoad := loadConfigFn originalK8s := newK8sClientFn diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4b561c6..5c6d5df 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -175,6 +175,25 @@ func TestLoadSupportsResticAndB2Overrides(t *testing.T) { } } +func TestLoadInfersB2EnablementAndSecretNamespaceDefault(t *testing.T) { + clearConfigEnv(t) + withEnv(t, "SOTERIA_NAMESPACE", "atlas") + withEnv(t, "SOTERIA_B2_SECRET_NAME", "b2-creds") + withEnv(t, "SOTERIA_B2_ACCESS_KEY_ID", "abc") + withEnv(t, "SOTERIA_B2_SECRET_ACCESS_KEY", "def") + + cfg, err := Load() + if err != nil { + t.Fatalf("load config with inferred b2 enablement: %v", err) + } + if !cfg.B2Enabled { + t.Fatalf("expected B2 enablement to be inferred from secret config") + } + if cfg.B2SecretNamespace != "atlas" { + t.Fatalf("expected B2 secret namespace to default to service namespace, got %#v", cfg.B2SecretNamespace) + } +} + func TestLoadRejectsInvalidConfigurations(t *testing.T) { testCases := []struct { name string @@ -184,7 +203,7 @@ func TestLoadRejectsInvalidConfigurations(t *testing.T) { { name: "restic missing repository", env: map[string]string{ - "SOTERIA_BACKUP_DRIVER": "restic", + "SOTERIA_BACKUP_DRIVER": "restic", "SOTERIA_RESTIC_REPOSITORY": "", }, substr: "SOTERIA_RESTIC_REPOSITORY is required", @@ -196,6 +215,15 @@ func TestLoadRejectsInvalidConfigurations(t *testing.T) { }, substr: "SOTERIA_JOB_NODE_SELECTOR is invalid", }, + { + name: "restic invalid repository path", + env: map[string]string{ + "SOTERIA_BACKUP_DRIVER": "restic", + "SOTERIA_RESTIC_REPOSITORY": "s3:https://repo/../bad", + "SOTERIA_RESTIC_SECRET_NAME": "restic-creds", + }, + substr: "SOTERIA_RESTIC_REPOSITORY contains invalid path segments", + }, { name: "unsupported driver", env: map[string]string{ @@ -234,12 +262,21 @@ func TestLoadRejectsInvalidConfigurations(t *testing.T) { { name: "invalid b2 scan interval", env: map[string]string{ - "SOTERIA_B2_ENABLED": "true", - "SOTERIA_B2_ENDPOINT": "https://s3.example.invalid", + "SOTERIA_B2_ENABLED": "true", + "SOTERIA_B2_ENDPOINT": "https://s3.example.invalid", "SOTERIA_B2_SCAN_INTERVAL_SECONDS": "0", }, substr: "SOTERIA_B2_SCAN_INTERVAL_SECONDS must be greater than zero", }, + { + name: "invalid b2 scan timeout", + env: map[string]string{ + "SOTERIA_B2_ENABLED": "true", + "SOTERIA_B2_ENDPOINT": "https://s3.example.invalid", + "SOTERIA_B2_SCAN_TIMEOUT_SECONDS": "0", + }, + substr: "SOTERIA_B2_SCAN_TIMEOUT_SECONDS must be greater than zero", + }, { name: "invalid policy eval interval", env: map[string]string{ @@ -296,6 +333,9 @@ func TestEnvAndParsingHelpers(t *testing.T) { if selector := parseNodeSelector("role=worker, hardware=rpi5"); selector["role"] != "worker" || selector["hardware"] != "rpi5" { t.Fatalf("unexpected selector parse: %#v", selector) } + if selector := parseNodeSelector("role=worker, , hardware=rpi5"); selector["role"] != "worker" || selector["hardware"] != "rpi5" { + t.Fatalf("expected empty selector segments to be ignored, got %#v", selector) + } if parseNodeSelector("role=") != nil { t.Fatalf("expected invalid selector to return nil") } diff --git a/internal/k8s/job_manifests_test.go b/internal/k8s/job_manifests_test.go new file mode 100644 index 0000000..ff31b3c --- /dev/null +++ b/internal/k8s/job_manifests_test.go @@ -0,0 +1,115 @@ +package k8s + +import ( + "strings" + "testing" + + "scm.bstein.dev/bstein/soteria/internal/api" + "scm.bstein.dev/bstein/soteria/internal/config" +) + +func TestBuildBackupJobAppliesSelectorsServiceAccountAndMetadata(t *testing.T) { + cfg := &config.Config{ + ResticImage: "restic/restic:latest", + ResticRepository: "s3:https://repo/root", + ResticBackupArgs: []string{"--exclude", "*.tmp"}, + ResticForgetArgs: []string{"--keep-daily", "7", "--prune"}, + S3Endpoint: "https://s3.us-west-001.backblazeb2.com", + S3Region: "us-west-001", + JobTTLSeconds: 3600, + JobNodeSelector: map[string]string{"hardware": "rpi5"}, + WorkerServiceAccount: "soteria-worker", + } + keepLast := 3 + req := api.BackupRequest{ + Namespace: "apps", + PVC: "data", + Tags: []string{"nightly"}, + KeepLast: &keepLast, + } + + job := buildBackupJob(cfg, req, "backup-job", "restic-secret", "s3:https://repo/root/isolated/apps/data", false, keepLast) + + if job.Name != "backup-job" || job.Namespace != "apps" { + t.Fatalf("expected backup job identity, got %#v", job.ObjectMeta) + } + if job.Labels[labelPVC] != "data" || job.Annotations[annotationDedupeEnabled] != "false" || job.Annotations[annotationKeepLast] != "3" { + t.Fatalf("expected pvc/dedupe/keep-last metadata, got labels=%#v annotations=%#v", job.Labels, job.Annotations) + } + if got := job.Spec.Template.Spec.NodeSelector["hardware"]; got != "rpi5" { + t.Fatalf("expected node selector to be applied, got %#v", job.Spec.Template.Spec.NodeSelector) + } + if got := job.Spec.Template.Spec.ServiceAccountName; got != "soteria-worker" { + t.Fatalf("expected worker service account, got %q", got) + } + if len(job.Spec.Template.Spec.Containers) != 1 { + t.Fatalf("expected one backup container, got %#v", job.Spec.Template.Spec.Containers) + } + container := job.Spec.Template.Spec.Containers[0] + if container.Name != "restic" || container.Image != "restic/restic:latest" { + t.Fatalf("expected restic container, got %#v", container) + } + if len(container.Args) != 1 || !strings.Contains(container.Args[0], "restic backup /data") || !strings.Contains(container.Args[0], "--keep-last 3") { + t.Fatalf("expected backup command payload, got %#v", container.Args) + } + if len(job.Spec.Template.Spec.Volumes) != 2 || job.Spec.Template.Spec.Volumes[0].PersistentVolumeClaim == nil { + t.Fatalf("expected pvc + cache volumes, got %#v", job.Spec.Template.Spec.Volumes) + } + if got := *job.Spec.BackoffLimit; got != 0 { + t.Fatalf("expected zero backoff limit, got %d", got) + } + if got := *job.Spec.TTLSecondsAfterFinished; got != 3600 { + t.Fatalf("expected ttl from config, got %d", got) + } +} + +func TestBuildRestoreJobCoversDefaultAndTargetPVCRestoreShapes(t *testing.T) { + cfg := &config.Config{ + ResticImage: "restic/restic:latest", + ResticRepository: "s3:https://repo/root", + S3Endpoint: "https://s3.us-west-001.backblazeb2.com", + S3Region: "us-west-001", + JobTTLSeconds: 1800, + JobNodeSelector: map[string]string{"hardware": "rpi5"}, + WorkerServiceAccount: "soteria-worker", + } + + defaultJob := buildRestoreJob(cfg, api.RestoreTestRequest{ + Namespace: "apps", + }, "restore-job", "restic-secret", "latest", "s3:https://repo/root") + if got := defaultJob.Spec.Template.Spec.Volumes[0].EmptyDir; got == nil { + t.Fatalf("expected default restore target to use emptyDir, got %#v", defaultJob.Spec.Template.Spec.Volumes[0]) + } + if got := defaultJob.Spec.Template.Spec.ServiceAccountName; got != "soteria-worker" { + t.Fatalf("expected worker service account, got %q", got) + } + if got := defaultJob.Spec.Template.Spec.NodeSelector["hardware"]; got != "rpi5" { + t.Fatalf("expected node selector to be applied, got %#v", defaultJob.Spec.Template.Spec.NodeSelector) + } + + targetPVCJob := buildRestoreJob(cfg, api.RestoreTestRequest{ + Namespace: "apps", + TargetPVC: "restore-data", + }, "restore-job", "restic-secret", "snapshot-1", "s3:https://repo/root") + if targetPVCJob.Labels[labelPVC] != "restore-data" { + t.Fatalf("expected restore pvc label, got %#v", targetPVCJob.Labels) + } + if claim := targetPVCJob.Spec.Template.Spec.Volumes[0].PersistentVolumeClaim; claim == nil || claim.ClaimName != "restore-data" { + t.Fatalf("expected target pvc volume mount, got %#v", targetPVCJob.Spec.Template.Spec.Volumes[0]) + } + if len(targetPVCJob.Spec.Template.Spec.Containers) != 1 || !strings.Contains(targetPVCJob.Spec.Template.Spec.Containers[0].Args[0], "restic restore snapshot-1") { + t.Fatalf("expected restore command payload, got %#v", targetPVCJob.Spec.Template.Spec.Containers) + } +} + +func TestRestoreCommandAndInt32PtrHelpers(t *testing.T) { + command := restoreCommand("snapshot-42") + if !strings.Contains(command, "restic restore snapshot-42") || !strings.Contains(command, "cp -a /cache/restore") { + t.Fatalf("expected restore shell command, got %q", command) + } + + value := int32Ptr(17) + if value == nil || *value != 17 { + t.Fatalf("expected int32 pointer helper to preserve value, got %#v", value) + } +} diff --git a/internal/k8s/state_test.go b/internal/k8s/state_test.go index ff4b4d1..5bec539 100644 --- a/internal/k8s/state_test.go +++ b/internal/k8s/state_test.go @@ -19,6 +19,14 @@ func TestLoadSecretDataCoversMissingSecretValueAndCopy(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "filled", Namespace: "atlas"}, Data: map[string][]byte{"token": []byte("atlas-secret")}, }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "missing-key", Namespace: "atlas"}, + Data: map[string][]byte{"other": []byte("atlas-secret")}, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "empty-value", Namespace: "atlas"}, + Data: map[string][]byte{"token": {}}, + }, &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{Name: "empty", Namespace: "atlas"}, }, @@ -34,6 +42,16 @@ func TestLoadSecretDataCoversMissingSecretValueAndCopy(t *testing.T) { t.Fatalf("expected empty secret key to return nil, got %q %v", string(value), err) } + value, err = client.LoadSecretData(context.Background(), "atlas", "missing-key", "token") + if err != nil || value != nil { + t.Fatalf("expected missing data key to return nil, got %q %v", string(value), err) + } + + value, err = client.LoadSecretData(context.Background(), "atlas", "empty-value", "token") + if err != nil || value != nil { + t.Fatalf("expected empty data value to return nil, got %q %v", string(value), err) + } + value, err = client.LoadSecretData(context.Background(), "atlas", "filled", "token") if err != nil || string(value) != "atlas-secret" { t.Fatalf("expected copied secret value, got %q %v", string(value), err) @@ -96,6 +114,38 @@ func TestSaveSecretDataCreatesAndUpdatesSecrets(t *testing.T) { } } +func TestSaveSecretDataInitializesNilDataAndLabelsOnExistingSecret(t *testing.T) { + client := &Client{Clientset: k8sfake.NewSimpleClientset()} + if _, err := client.Clientset.CoreV1().Secrets("atlas").Create(context.Background(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "restic-usage", Namespace: "atlas"}, + }, metav1.CreateOptions{}); err != nil { + t.Fatalf("seed secret: %v", err) + } + seeded, err := client.Clientset.CoreV1().Secrets("atlas").Get(context.Background(), "restic-usage", metav1.GetOptions{}) + if err != nil { + t.Fatalf("get seeded secret: %v", err) + } + seeded.ResourceVersion = "1" + if _, err := client.Clientset.CoreV1().Secrets("atlas").Update(context.Background(), seeded, metav1.UpdateOptions{}); err != nil { + t.Fatalf("prime seeded secret resource version: %v", err) + } + + if err := client.SaveSecretData(context.Background(), "atlas", "restic-usage", "usage.json", []byte("value"), map[string]string{"app": "soteria"}); err != nil { + t.Fatalf("save secret data with nil data/labels: %v", err) + } + + updated, err := client.Clientset.CoreV1().Secrets("atlas").Get(context.Background(), "restic-usage", metav1.GetOptions{}) + if err != nil { + t.Fatalf("get updated secret: %v", err) + } + if string(updated.Data["usage.json"]) != "value" { + t.Fatalf("expected updated secret payload, got %#v", updated.Data) + } + if updated.Labels["app"] != "soteria" { + t.Fatalf("expected labels to be initialized, got %#v", updated.Labels) + } +} + func TestSaveSecretDataWrapsGetAndWriteErrors(t *testing.T) { t.Run("get error", func(t *testing.T) { clientset := k8sfake.NewSimpleClientset() diff --git a/internal/k8s/volumes_test.go b/internal/k8s/volumes_test.go index 08baf79..b5f02a7 100644 --- a/internal/k8s/volumes_test.go +++ b/internal/k8s/volumes_test.go @@ -112,6 +112,15 @@ func TestListBoundPVCsAndExistsCoversFilteringSortingAndCapacityFallback(t *test if err != nil || exists { t.Fatalf("expected pvc to be missing, got %v %v", exists, err) } + + clientset := k8sfake.NewSimpleClientset() + clientset.PrependReactor("list", "persistentvolumeclaims", func(action k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, apierrors.NewForbidden(schema.GroupResource{Resource: "persistentvolumeclaims"}, "", nil) + }) + client = &Client{Clientset: clientset} + if _, err := client.ListBoundPVCs(context.Background()); err == nil { + t.Fatalf("expected wrapped pvc list error") + } } func TestPersistentVolumeClaimExistsWrapsUnexpectedErrors(t *testing.T) { diff --git a/internal/server/backup_handlers_test.go b/internal/server/backup_handlers_test.go index 32a837c..c334c59 100644 --- a/internal/server/backup_handlers_test.go +++ b/internal/server/backup_handlers_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "scm.bstein.dev/bstein/soteria/internal/api" "scm.bstein.dev/bstein/soteria/internal/config" @@ -191,6 +192,100 @@ func TestHandleBackupsRejectsInvalidRequestsAndBackendErrors(t *testing.T) { }) } +func TestBackupHandlersSuccessPaths(t *testing.T) { + t.Run("inventory success", func(t *testing.T) { + srv := newBackupTestServer( + &config.Config{AuthRequired: false, BackupDriver: "longhorn", BackupMaxAge: 24 * time.Hour}, + &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{ + fakeKubeClient: &fakeKubeClient{ + pvcs: []k8s.PVCSummary{{Namespace: "apps", Name: "data", VolumeName: "pv-data", Phase: "Bound"}}, + }, + }}, + &backupTestLonghornClient{ + restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}, + listBackups: []longhorn.Backup{{Name: "backup-a", Created: time.Now().UTC().Format(time.RFC3339), State: "Completed", Size: "1Gi"}}, + }, + ) + + req := httptest.NewRequest(http.MethodGet, "/v1/inventory", nil) + res := httptest.NewRecorder() + srv.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), `"namespaces"`) { + t.Fatalf("expected inventory success, got %d %s", res.Code, res.Body.String()) + } + }) + + t.Run("backups success longhorn", func(t *testing.T) { + srv := newBackupTestServer( + &config.Config{AuthRequired: false, BackupDriver: "longhorn"}, + &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{fakeKubeClient: &fakeKubeClient{}}}, + &backupTestLonghornClient{ + restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}, + listBackups: []longhorn.Backup{{Name: "backup-a", SnapshotName: "snap-a", Created: "2026-04-20T00:00:00Z", State: "Completed", URL: "s3://bucket/a", Size: "1Gi"}}, + }, + ) + req := httptest.NewRequest(http.MethodGet, "/v1/backups?namespace=apps&pvc=data", nil) + res := httptest.NewRecorder() + srv.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), `"backup-a"`) { + t.Fatalf("expected longhorn backups success, got %d %s", res.Code, res.Body.String()) + } + }) + + t.Run("backups success restic", func(t *testing.T) { + srv := newBackupTestServer( + &config.Config{AuthRequired: false, BackupDriver: "restic", ResticRepository: "s3:https://repo/root"}, + &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{fakeKubeClient: &fakeKubeClient{ + backupJobs: map[string][]k8s.BackupJobSummary{ + "apps/data": {{Name: "restic-job", Namespace: "apps", PVC: "data", State: "Completed", Repository: "s3:https://repo/root", CreatedAt: time.Now().UTC()}}, + }, + }}}, + &backupTestLonghornClient{restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}}, + ) + req := httptest.NewRequest(http.MethodGet, "/v1/backups?namespace=apps&pvc=data", nil) + res := httptest.NewRecorder() + srv.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), `"restic-job"`) { + t.Fatalf("expected restic backups success, got %d %s", res.Code, res.Body.String()) + } + }) + + t.Run("handle backup success", func(t *testing.T) { + srv := newBackupTestServer( + &config.Config{AuthRequired: false, BackupDriver: "restic"}, + &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{fakeKubeClient: &fakeKubeClient{}}}, + &backupTestLonghornClient{restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}}, + ) + req := httptest.NewRequest(http.MethodPost, "/v1/backup", strings.NewReader(`{"namespace":"apps","pvc":"data","dry_run":true}`)) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + srv.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), `"dry_run":true`) { + t.Fatalf("expected backup success, got %d %s", res.Code, res.Body.String()) + } + }) + + t.Run("namespace backup success", func(t *testing.T) { + srv := newBackupTestServer( + &config.Config{AuthRequired: false, BackupDriver: "restic"}, + &backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{fakeKubeClient: &fakeKubeClient{ + pvcs: []k8s.PVCSummary{ + {Namespace: "apps", Name: "alpha", VolumeName: "pv-alpha", Phase: "Bound"}, + {Namespace: "apps", Name: "beta", VolumeName: "pv-beta", Phase: "Bound"}, + }, + }}}, + &backupTestLonghornClient{restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{}}}, + ) + req := httptest.NewRequest(http.MethodPost, "/v1/backup/namespace", strings.NewReader(`{"namespace":"apps","dry_run":true}`)) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + srv.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), `"total":2`) || !strings.Contains(res.Body.String(), `"dry_run":true`) { + t.Fatalf("expected namespace backup success, got %d %s", res.Code, res.Body.String()) + } + }) +} + func TestHandleBackupAndNamespaceBackupValidationPaths(t *testing.T) { t.Run("backup request validation", func(t *testing.T) { srv := newBackupTestServer(