338 lines
11 KiB
Go
338 lines
11 KiB
Go
|
|
package server
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"math"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"scm.bstein.dev/bstein/soteria/internal/config"
|
||
|
|
)
|
||
|
|
|
||
|
|
type resticUsageTestKubeClient struct {
|
||
|
|
*fakeKubeClient
|
||
|
|
loadSecretDataErr error
|
||
|
|
saveSecretDataErr error
|
||
|
|
readBackupLogErr error
|
||
|
|
loadCalls int
|
||
|
|
saveCalls int
|
||
|
|
readCalls int
|
||
|
|
}
|
||
|
|
|
||
|
|
func (k *resticUsageTestKubeClient) LoadSecretData(ctx context.Context, namespace, secretName, key string) ([]byte, error) {
|
||
|
|
k.loadCalls++
|
||
|
|
if k.loadSecretDataErr != nil {
|
||
|
|
return nil, k.loadSecretDataErr
|
||
|
|
}
|
||
|
|
return k.fakeKubeClient.LoadSecretData(ctx, namespace, secretName, key)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (k *resticUsageTestKubeClient) SaveSecretData(ctx context.Context, namespace, secretName, key string, value []byte, labels map[string]string) error {
|
||
|
|
k.saveCalls++
|
||
|
|
if k.saveSecretDataErr != nil {
|
||
|
|
return k.saveSecretDataErr
|
||
|
|
}
|
||
|
|
return k.fakeKubeClient.SaveSecretData(ctx, namespace, secretName, key, value, labels)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (k *resticUsageTestKubeClient) ReadBackupJobLog(ctx context.Context, namespace, jobName string) (string, error) {
|
||
|
|
k.readCalls++
|
||
|
|
if k.readBackupLogErr != nil {
|
||
|
|
return "", k.readBackupLogErr
|
||
|
|
}
|
||
|
|
return k.fakeKubeClient.ReadBackupJobLog(ctx, namespace, jobName)
|
||
|
|
}
|
||
|
|
|
||
|
|
func newResticUsageTestServer(cfg *config.Config, client kubeClient) *Server {
|
||
|
|
return &Server{
|
||
|
|
cfg: cfg,
|
||
|
|
client: client,
|
||
|
|
metrics: newTelemetry(),
|
||
|
|
jobUsage: map[string]resticJobUsageCacheEntry{},
|
||
|
|
usageStore: map[string]resticPersistedUsageEntry{},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestParseHumanByteSizeCoversSupportedUnits(t *testing.T) {
|
||
|
|
testCases := []struct {
|
||
|
|
name string
|
||
|
|
raw string
|
||
|
|
want float64
|
||
|
|
ok bool
|
||
|
|
}{
|
||
|
|
{name: "bytes", raw: "42 B", want: 42, ok: true},
|
||
|
|
{name: "kib", raw: "1.5 KiB", want: 1536, ok: true},
|
||
|
|
{name: "mib", raw: "2 MiB", want: 2 * 1024 * 1024, ok: true},
|
||
|
|
{name: "gib", raw: "3 GiB", want: 3 * 1024 * 1024 * 1024, ok: true},
|
||
|
|
{name: "tib", raw: "4 TiB", want: 4 * 1024 * 1024 * 1024 * 1024, ok: true},
|
||
|
|
{name: "kb", raw: "2 KB", want: 2000, ok: true},
|
||
|
|
{name: "mb", raw: "3 MB", want: 3000000, ok: true},
|
||
|
|
{name: "gb", raw: "4 GB", want: 4000000000, ok: true},
|
||
|
|
{name: "tb", raw: "5 TB", want: 5000000000000, ok: true},
|
||
|
|
{name: "comma separated", raw: "1,024 KB", want: 1024000, ok: true},
|
||
|
|
{name: "missing unit", raw: "123", want: 0, ok: false},
|
||
|
|
{name: "bad number", raw: "abc MiB", want: 0, ok: false},
|
||
|
|
{name: "unsupported unit", raw: "10 XB", want: 0, ok: false},
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, tc := range testCases {
|
||
|
|
got, ok := parseHumanByteSize(tc.raw)
|
||
|
|
if ok != tc.ok {
|
||
|
|
t.Fatalf("%s: expected ok=%v, got %v", tc.name, tc.ok, ok)
|
||
|
|
}
|
||
|
|
if ok && got != tc.want {
|
||
|
|
t.Fatalf("%s: expected %f, got %f", tc.name, tc.want, got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLoadResticUsageCoversNoopDecodeAndFiltering(t *testing.T) {
|
||
|
|
t.Run("no secret configured", func(t *testing.T) {
|
||
|
|
client := &resticUsageTestKubeClient{fakeKubeClient: &fakeKubeClient{}}
|
||
|
|
srv := newResticUsageTestServer(&config.Config{}, client)
|
||
|
|
|
||
|
|
if err := srv.loadResticUsage(context.Background()); err != nil {
|
||
|
|
t.Fatalf("load restic usage without secret: %v", err)
|
||
|
|
}
|
||
|
|
if client.loadCalls != 0 {
|
||
|
|
t.Fatalf("expected no secret reads, got %d", client.loadCalls)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("load error", func(t *testing.T) {
|
||
|
|
client := &resticUsageTestKubeClient{
|
||
|
|
fakeKubeClient: &fakeKubeClient{},
|
||
|
|
loadSecretDataErr: errors.New("load exploded"),
|
||
|
|
}
|
||
|
|
srv := newResticUsageTestServer(&config.Config{
|
||
|
|
Namespace: "atlas",
|
||
|
|
UsageSecretName: "restic-usage",
|
||
|
|
}, client)
|
||
|
|
|
||
|
|
if err := srv.loadResticUsage(context.Background()); err == nil || err.Error() != "load exploded" {
|
||
|
|
t.Fatalf("expected load error, got %v", err)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("decode error", func(t *testing.T) {
|
||
|
|
client := &resticUsageTestKubeClient{
|
||
|
|
fakeKubeClient: &fakeKubeClient{
|
||
|
|
secretData: map[string][]byte{usageSecretKey: []byte(`{bad json`)},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
srv := newResticUsageTestServer(&config.Config{
|
||
|
|
Namespace: "atlas",
|
||
|
|
UsageSecretName: "restic-usage",
|
||
|
|
}, client)
|
||
|
|
|
||
|
|
if err := srv.loadResticUsage(context.Background()); err == nil || err.Error() == "" {
|
||
|
|
t.Fatalf("expected decode error, got %v", err)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("filters invalid entries", func(t *testing.T) {
|
||
|
|
client := &resticUsageTestKubeClient{
|
||
|
|
fakeKubeClient: &fakeKubeClient{
|
||
|
|
secretData: map[string][]byte{
|
||
|
|
usageSecretKey: []byte(`{
|
||
|
|
"jobs":[
|
||
|
|
{"key":"apps/job-a","bytes":1024,"updated_at":"2026-04-20T00:00:00Z"},
|
||
|
|
{"key":" ","bytes":2048,"updated_at":"2026-04-20T00:00:00Z"},
|
||
|
|
{"key":"apps/job-b","bytes":-1,"updated_at":"2026-04-20T00:00:00Z"}
|
||
|
|
]
|
||
|
|
}`),
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
srv := newResticUsageTestServer(&config.Config{
|
||
|
|
Namespace: "atlas",
|
||
|
|
UsageSecretName: "restic-usage",
|
||
|
|
}, client)
|
||
|
|
|
||
|
|
if err := srv.loadResticUsage(context.Background()); err != nil {
|
||
|
|
t.Fatalf("load filtered restic usage: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if len(srv.usageStore) != 1 {
|
||
|
|
t.Fatalf("expected only one valid stored entry, got %#v", srv.usageStore)
|
||
|
|
}
|
||
|
|
if got, ok := srv.lookupPersistedResticUsage("apps/job-a"); !ok || got != 1024 {
|
||
|
|
t.Fatalf("expected valid persisted entry, got %f %v", got, ok)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLookupPersistedResticUsageRejectsInvalidStoredValues(t *testing.T) {
|
||
|
|
srv := &Server{
|
||
|
|
usageStore: map[string]resticPersistedUsageEntry{
|
||
|
|
"good": {Bytes: 4096},
|
||
|
|
"neg": {Bytes: -1},
|
||
|
|
"nan": {Bytes: math.NaN()},
|
||
|
|
"pos-inf": {Bytes: math.Inf(1)},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
if got, ok := srv.lookupPersistedResticUsage("good"); !ok || got != 4096 {
|
||
|
|
t.Fatalf("expected good persisted entry, got %f %v", got, ok)
|
||
|
|
}
|
||
|
|
for _, key := range []string{"missing", "neg", "nan", "pos-inf"} {
|
||
|
|
if got, ok := srv.lookupPersistedResticUsage(key); ok || got != 0 {
|
||
|
|
t.Fatalf("%s: expected missing/invalid lookup, got %f %v", key, got, ok)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPersistResticUsageEncodesSortedFilteredDocument(t *testing.T) {
|
||
|
|
client := &resticUsageTestKubeClient{fakeKubeClient: &fakeKubeClient{}}
|
||
|
|
srv := newResticUsageTestServer(&config.Config{
|
||
|
|
Namespace: "atlas",
|
||
|
|
UsageSecretName: "restic-usage",
|
||
|
|
}, client)
|
||
|
|
srv.usageStore = map[string]resticPersistedUsageEntry{
|
||
|
|
"apps/job-b": {Bytes: 2048, UpdatedAt: "2026-04-20T01:00:00Z"},
|
||
|
|
"apps/job-a": {Bytes: 1024, UpdatedAt: " 2026-04-20T00:00:00Z "},
|
||
|
|
"": {Bytes: 1},
|
||
|
|
"apps/bad": {Bytes: -1},
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := srv.persistResticUsage(context.Background()); err != nil {
|
||
|
|
t.Fatalf("persist restic usage: %v", err)
|
||
|
|
}
|
||
|
|
if client.saveCalls != 1 {
|
||
|
|
t.Fatalf("expected one save call, got %d", client.saveCalls)
|
||
|
|
}
|
||
|
|
|
||
|
|
raw := client.fakeKubeClient.secretData[usageSecretKey]
|
||
|
|
var doc resticPersistedUsageDocument
|
||
|
|
if err := json.Unmarshal(raw, &doc); err != nil {
|
||
|
|
t.Fatalf("decode persisted usage document: %v", err)
|
||
|
|
}
|
||
|
|
if len(doc.Jobs) != 2 {
|
||
|
|
t.Fatalf("expected two valid persisted jobs, got %#v", doc.Jobs)
|
||
|
|
}
|
||
|
|
if doc.Jobs[0].Key != "apps/job-a" || doc.Jobs[1].Key != "apps/job-b" {
|
||
|
|
t.Fatalf("expected sorted job keys, got %#v", doc.Jobs)
|
||
|
|
}
|
||
|
|
if doc.Jobs[0].UpdatedAt != "2026-04-20T00:00:00Z" {
|
||
|
|
t.Fatalf("expected trimmed timestamp, got %#v", doc.Jobs[0])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestStorePersistedResticUsageCoversNoopAndUpdateBranches(t *testing.T) {
|
||
|
|
client := &resticUsageTestKubeClient{fakeKubeClient: &fakeKubeClient{}}
|
||
|
|
srv := newResticUsageTestServer(&config.Config{
|
||
|
|
Namespace: "atlas",
|
||
|
|
UsageSecretName: "restic-usage",
|
||
|
|
}, client)
|
||
|
|
|
||
|
|
srv.storePersistedResticUsage(context.Background(), "", 123)
|
||
|
|
srv.storePersistedResticUsage(context.Background(), "apps/job-a", -1)
|
||
|
|
srv.storePersistedResticUsage(context.Background(), "apps/job-a", math.NaN())
|
||
|
|
srv.storePersistedResticUsage(context.Background(), "apps/job-a", math.Inf(1))
|
||
|
|
if client.saveCalls != 0 {
|
||
|
|
t.Fatalf("expected invalid inputs to skip persistence, got %d saves", client.saveCalls)
|
||
|
|
}
|
||
|
|
|
||
|
|
srv.storePersistedResticUsage(context.Background(), "apps/job-a", 2048)
|
||
|
|
entry, ok := srv.usageStore["apps/job-a"]
|
||
|
|
if !ok || entry.Bytes != 2048 || entry.UpdatedAt == "" {
|
||
|
|
t.Fatalf("expected stored usage entry, got %#v %v", entry, ok)
|
||
|
|
}
|
||
|
|
if client.saveCalls != 1 {
|
||
|
|
t.Fatalf("expected one save after new entry, got %d", client.saveCalls)
|
||
|
|
}
|
||
|
|
|
||
|
|
srv.storePersistedResticUsage(context.Background(), "apps/job-a", 2048)
|
||
|
|
if client.saveCalls != 1 {
|
||
|
|
t.Fatalf("expected unchanged entry to skip persistence, got %d saves", client.saveCalls)
|
||
|
|
}
|
||
|
|
|
||
|
|
srv.usageStore["apps/job-b"] = resticPersistedUsageEntry{Bytes: 512}
|
||
|
|
srv.storePersistedResticUsage(context.Background(), "apps/job-b", 512)
|
||
|
|
if client.saveCalls != 2 {
|
||
|
|
t.Fatalf("expected blank timestamp entry to repersist, got %d saves", client.saveCalls)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLookupResticStoredBytesForJobCoversCachePersistedAndLogFallback(t *testing.T) {
|
||
|
|
t.Run("fresh cache", func(t *testing.T) {
|
||
|
|
client := &resticUsageTestKubeClient{fakeKubeClient: &fakeKubeClient{}}
|
||
|
|
srv := newResticUsageTestServer(&config.Config{}, client)
|
||
|
|
srv.jobUsage["apps/job-a"] = resticJobUsageCacheEntry{
|
||
|
|
Known: true,
|
||
|
|
Bytes: 512,
|
||
|
|
CheckedAt: time.Now().UTC(),
|
||
|
|
}
|
||
|
|
|
||
|
|
got, ok := srv.lookupResticStoredBytesForJob(context.Background(), "apps", "job-a")
|
||
|
|
if !ok || got != 512 {
|
||
|
|
t.Fatalf("expected fresh cached bytes, got %f %v", got, ok)
|
||
|
|
}
|
||
|
|
if client.readCalls != 0 {
|
||
|
|
t.Fatalf("expected no log reads for fresh cache, got %d", client.readCalls)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("persisted usage populates cache", func(t *testing.T) {
|
||
|
|
client := &resticUsageTestKubeClient{fakeKubeClient: &fakeKubeClient{}}
|
||
|
|
srv := newResticUsageTestServer(&config.Config{}, client)
|
||
|
|
srv.usageStore["apps/job-a"] = resticPersistedUsageEntry{Bytes: 1024}
|
||
|
|
|
||
|
|
got, ok := srv.lookupResticStoredBytesForJob(context.Background(), "apps", "job-a")
|
||
|
|
if !ok || got != 1024 {
|
||
|
|
t.Fatalf("expected persisted bytes, got %f %v", got, ok)
|
||
|
|
}
|
||
|
|
cached := srv.jobUsage["apps/job-a"]
|
||
|
|
if !cached.Known || cached.Bytes != 1024 {
|
||
|
|
t.Fatalf("expected cache warm from persisted usage, got %#v", cached)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("job log parse stores persisted usage", func(t *testing.T) {
|
||
|
|
client := &resticUsageTestKubeClient{
|
||
|
|
fakeKubeClient: &fakeKubeClient{
|
||
|
|
jobLogs: map[string]string{
|
||
|
|
"apps/job-a": `{"message_type":"summary","data_added":2048}`,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
srv := newResticUsageTestServer(&config.Config{
|
||
|
|
Namespace: "atlas",
|
||
|
|
UsageSecretName: "restic-usage",
|
||
|
|
}, client)
|
||
|
|
|
||
|
|
got, ok := srv.lookupResticStoredBytesForJob(context.Background(), "apps", "job-a")
|
||
|
|
if !ok || got != 2048 {
|
||
|
|
t.Fatalf("expected parsed bytes from job log, got %f %v", got, ok)
|
||
|
|
}
|
||
|
|
if client.readCalls != 1 {
|
||
|
|
t.Fatalf("expected one job log read, got %d", client.readCalls)
|
||
|
|
}
|
||
|
|
if client.saveCalls != 1 {
|
||
|
|
t.Fatalf("expected parsed bytes to persist, got %d saves", client.saveCalls)
|
||
|
|
}
|
||
|
|
if persisted, ok := srv.lookupPersistedResticUsage("apps/job-a"); !ok || persisted != 2048 {
|
||
|
|
t.Fatalf("expected persisted bytes after log parse, got %f %v", persisted, ok)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("log read failure caches unknown result", func(t *testing.T) {
|
||
|
|
client := &resticUsageTestKubeClient{
|
||
|
|
fakeKubeClient: &fakeKubeClient{},
|
||
|
|
readBackupLogErr: errors.New("log exploded"),
|
||
|
|
}
|
||
|
|
srv := newResticUsageTestServer(&config.Config{}, client)
|
||
|
|
|
||
|
|
got, ok := srv.lookupResticStoredBytesForJob(context.Background(), "apps", "job-a")
|
||
|
|
if ok || got != 0 {
|
||
|
|
t.Fatalf("expected unknown result on log read failure, got %f %v", got, ok)
|
||
|
|
}
|
||
|
|
cached := srv.jobUsage["apps/job-a"]
|
||
|
|
if cached.Known {
|
||
|
|
t.Fatalf("expected unknown cache entry, got %#v", cached)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|