test(soteria): finish per-file coverage floor
This commit is contained in:
parent
683014443d
commit
f7a08eb420
@ -3,9 +3,11 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"scm.bstein.dev/bstein/soteria/internal/config"
|
"scm.bstein.dev/bstein/soteria/internal/config"
|
||||||
"scm.bstein.dev/bstein/soteria/internal/k8s"
|
"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() {
|
func swapMainTestHooks() func() {
|
||||||
originalLoad := loadConfigFn
|
originalLoad := loadConfigFn
|
||||||
originalK8s := newK8sClientFn
|
originalK8s := newK8sClientFn
|
||||||
|
|||||||
@ -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) {
|
func TestLoadRejectsInvalidConfigurations(t *testing.T) {
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
@ -196,6 +215,15 @@ func TestLoadRejectsInvalidConfigurations(t *testing.T) {
|
|||||||
},
|
},
|
||||||
substr: "SOTERIA_JOB_NODE_SELECTOR is invalid",
|
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",
|
name: "unsupported driver",
|
||||||
env: map[string]string{
|
env: map[string]string{
|
||||||
@ -240,6 +268,15 @@ func TestLoadRejectsInvalidConfigurations(t *testing.T) {
|
|||||||
},
|
},
|
||||||
substr: "SOTERIA_B2_SCAN_INTERVAL_SECONDS must be greater than zero",
|
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",
|
name: "invalid policy eval interval",
|
||||||
env: map[string]string{
|
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" {
|
if selector := parseNodeSelector("role=worker, hardware=rpi5"); selector["role"] != "worker" || selector["hardware"] != "rpi5" {
|
||||||
t.Fatalf("unexpected selector parse: %#v", selector)
|
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 {
|
if parseNodeSelector("role=") != nil {
|
||||||
t.Fatalf("expected invalid selector to return nil")
|
t.Fatalf("expected invalid selector to return nil")
|
||||||
}
|
}
|
||||||
|
|||||||
115
internal/k8s/job_manifests_test.go
Normal file
115
internal/k8s/job_manifests_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,6 +19,14 @@ func TestLoadSecretDataCoversMissingSecretValueAndCopy(t *testing.T) {
|
|||||||
ObjectMeta: metav1.ObjectMeta{Name: "filled", Namespace: "atlas"},
|
ObjectMeta: metav1.ObjectMeta{Name: "filled", Namespace: "atlas"},
|
||||||
Data: map[string][]byte{"token": []byte("atlas-secret")},
|
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{
|
&corev1.Secret{
|
||||||
ObjectMeta: metav1.ObjectMeta{Name: "empty", Namespace: "atlas"},
|
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)
|
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")
|
value, err = client.LoadSecretData(context.Background(), "atlas", "filled", "token")
|
||||||
if err != nil || string(value) != "atlas-secret" {
|
if err != nil || string(value) != "atlas-secret" {
|
||||||
t.Fatalf("expected copied secret value, got %q %v", string(value), err)
|
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) {
|
func TestSaveSecretDataWrapsGetAndWriteErrors(t *testing.T) {
|
||||||
t.Run("get error", func(t *testing.T) {
|
t.Run("get error", func(t *testing.T) {
|
||||||
clientset := k8sfake.NewSimpleClientset()
|
clientset := k8sfake.NewSimpleClientset()
|
||||||
|
|||||||
@ -112,6 +112,15 @@ func TestListBoundPVCsAndExistsCoversFilteringSortingAndCapacityFallback(t *test
|
|||||||
if err != nil || exists {
|
if err != nil || exists {
|
||||||
t.Fatalf("expected pvc to be missing, got %v %v", exists, err)
|
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) {
|
func TestPersistentVolumeClaimExistsWrapsUnexpectedErrors(t *testing.T) {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"scm.bstein.dev/bstein/soteria/internal/api"
|
"scm.bstein.dev/bstein/soteria/internal/api"
|
||||||
"scm.bstein.dev/bstein/soteria/internal/config"
|
"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) {
|
func TestHandleBackupAndNamespaceBackupValidationPaths(t *testing.T) {
|
||||||
t.Run("backup request validation", func(t *testing.T) {
|
t.Run("backup request validation", func(t *testing.T) {
|
||||||
srv := newBackupTestServer(
|
srv := newBackupTestServer(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user