soteria/internal/k8s/longhorn_backups_test.go

150 lines
6.2 KiB
Go

package k8s
import (
"context"
"errors"
"strings"
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
dynamicfake "k8s.io/client-go/dynamic/fake"
k8stesting "k8s.io/client-go/testing"
)
func TestGetLonghornBackupBySnapshotCoversLookupPaths(t *testing.T) {
matchingOlder := longhornBackupObject("backup-old", "snap-a", "Completed", "", 100, "2026-04-20T10:00:00Z")
matchingNewer := longhornBackupObject("backup-new", "snap-a", "Error", "storage cap exceeded", 0, "2026-04-20T11:00:00Z")
other := longhornBackupObject("backup-other", "snap-b", "Completed", "", 100, "2026-04-20T12:00:00Z")
client := &Client{Dynamic: newLonghornBackupDynamic(matchingOlder, matchingNewer, other)}
backup, found, err := client.GetLonghornBackupBySnapshot(context.Background(), "snap-a")
if err != nil || !found {
t.Fatalf("expected matching backup, found=%v err=%v", found, err)
}
if backup.Name != "backup-new" || backup.State != "Error" || backup.Error != "storage cap exceeded" || backup.Progress != 0 {
t.Fatalf("expected newest matching backup with status fields, got %#v", backup)
}
if _, found, err := client.GetLonghornBackupBySnapshot(context.Background(), "missing"); err != nil || found {
t.Fatalf("expected missing snapshot to be cleanly absent, found=%v err=%v", found, err)
}
}
func TestGetLonghornBackupBySnapshotValidatesClientAndInput(t *testing.T) {
client := &Client{Dynamic: newLonghornBackupDynamic()}
if _, _, err := client.GetLonghornBackupBySnapshot(context.Background(), " "); err == nil || !strings.Contains(err.Error(), "snapshot name is required") {
t.Fatalf("expected snapshot validation error, got %v", err)
}
client.Dynamic = nil
if _, _, err := client.GetLonghornBackupBySnapshot(context.Background(), "snap-a"); err == nil || !strings.Contains(err.Error(), "dynamic Kubernetes client is unavailable") {
t.Fatalf("expected missing dynamic client error, got %v", err)
}
}
func TestGetLonghornBackupBySnapshotWrapsListErrorAndFallbackTimestamp(t *testing.T) {
client := &Client{Dynamic: newLonghornBackupDynamic(longhornBackupObject("backup-a", "snap-a", "Completed", "", 100, ""))}
backup, found, err := client.GetLonghornBackupBySnapshot(context.Background(), "snap-a")
if err != nil || !found {
t.Fatalf("expected fallback timestamp backup, found=%v err=%v", found, err)
}
if backup.CreatedAt == "" || backup.SnapshotName != "snap-a" {
t.Fatalf("expected fallback timestamp and snapshot name, got %#v", backup)
}
failingDynamic := newLonghornBackupDynamic()
failingDynamic.PrependReactor("list", "backups", func(k8stesting.Action) (bool, runtime.Object, error) {
return true, nil, errors.New("list exploded")
})
client.Dynamic = failingDynamic
if _, _, err := client.GetLonghornBackupBySnapshot(context.Background(), "snap-a"); err == nil || !strings.Contains(err.Error(), "list exploded") {
t.Fatalf("expected wrapped list error, got %v", err)
}
}
func TestListLonghornBackupsFiltersSoteriaLabelsAndSorts(t *testing.T) {
older := longhornBackupObject("backup-older", "snap-a", "Completed", "", 100, "2026-04-20T10:00:00Z")
newer := longhornBackupObject("backup-newer", "snap-b", "Error", "storage cap exceeded", 0, "2026-04-20T11:00:00Z")
setLonghornBackupLabels(newer, "ops", "cache")
unlabeled := longhornBackupObject("backup-unlabeled", "snap-c", "Completed", "", 100, "2026-04-20T12:00:00Z")
_ = unstructured.SetNestedStringMap(unlabeled.Object, map[string]string{}, "spec", "labels")
client := &Client{Dynamic: newLonghornBackupDynamic(older, newer, unlabeled)}
backups, err := client.ListLonghornBackups(context.Background())
if err != nil {
t.Fatalf("list Longhorn backups: %v", err)
}
if len(backups) != 2 {
t.Fatalf("expected two Soteria-labeled backups, got %#v", backups)
}
if backups[0].Name != "backup-newer" || backups[0].Namespace != "ops" || backups[0].PVC != "cache" {
t.Fatalf("expected newest ops/cache backup first, got %#v", backups[0])
}
if backups[1].Name != "backup-older" || backups[1].Namespace != "apps" || backups[1].PVC != "data" {
t.Fatalf("expected older apps/data backup second, got %#v", backups[1])
}
}
func TestListLonghornBackupsValidatesClientAndWrapsListError(t *testing.T) {
client := &Client{}
if _, err := client.ListLonghornBackups(context.Background()); err == nil || !strings.Contains(err.Error(), "dynamic Kubernetes client is unavailable") {
t.Fatalf("expected missing dynamic client error, got %v", err)
}
failingDynamic := newLonghornBackupDynamic()
failingDynamic.PrependReactor("list", "backups", func(k8stesting.Action) (bool, runtime.Object, error) {
return true, nil, errors.New("list exploded")
})
client.Dynamic = failingDynamic
if _, err := client.ListLonghornBackups(context.Background()); err == nil || !strings.Contains(err.Error(), "list exploded") {
t.Fatalf("expected wrapped list error, got %v", err)
}
}
func newLonghornBackupDynamic(objects ...runtime.Object) *dynamicfake.FakeDynamicClient {
return dynamicfake.NewSimpleDynamicClientWithCustomListKinds(
runtime.NewScheme(),
map[schema.GroupVersionResource]string{longhornBackupGVR: "BackupList"},
objects...,
)
}
func setLonghornBackupLabels(item *unstructured.Unstructured, namespace, pvc string) {
_ = unstructured.SetNestedStringMap(item.Object, map[string]string{
"soteria.bstein.dev/namespace": namespace,
"soteria.bstein.dev/pvc": pvc,
}, "spec", "labels")
}
func longhornBackupObject(name, snapshotName, state, errorMessage string, progress int64, createdAt string) *unstructured.Unstructured {
item := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "longhorn.io/v1beta2",
"kind": "Backup",
"metadata": map[string]any{
"name": name,
"namespace": longhornSystemNamespace,
},
"spec": map[string]any{
"snapshotName": snapshotName,
"labels": map[string]any{
"soteria.bstein.dev/namespace": "apps",
"soteria.bstein.dev/pvc": "data",
},
},
"status": map[string]any{
"state": state,
"error": errorMessage,
"progress": progress,
},
}}
if createdAt != "" {
_ = unstructured.SetNestedField(item.Object, createdAt, "status", "backupCreatedAt")
}
item.SetCreationTimestamp(metav1.Now())
return item
}