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) {
|
func TestProtectedInventoryRequiresAuth(t *testing.T) {
|
||||||
srv := &Server{
|
srv := &Server{
|
||||||
cfg: &config.Config{AuthRequired: true, AllowedGroups: []string{"admin", "maintenance"}, BackupDriver: "longhorn"},
|
cfg: &config.Config{AuthRequired: true, AllowedGroups: []string{"admin", "maintenance"}, BackupDriver: "longhorn"},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user