test(soteria): lift b2 and job helper coverage
This commit is contained in:
parent
2ec2edae4c
commit
0cda32777f
213
internal/k8s/jobs_test.go
Normal file
213
internal/k8s/jobs_test.go
Normal file
@ -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
|
||||
}
|
||||
@ -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"},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user