From 841f6f3868d2ebf2680ef329ec85857385babfd5 Mon Sep 17 00:00:00 2001 From: TheiLLeniumStudios <104288623+TheiLLeniumStudios@users.noreply.github.com> Date: Sun, 28 Dec 2025 08:47:57 +0100 Subject: [PATCH] feat: Improve test coverage of important packages --- internal/pkg/controller/filter_test.go | 197 +++++ internal/pkg/controller/retry_test.go | 131 ++++ internal/pkg/reload/decision_test.go | 113 +++ internal/pkg/reload/predicate_test.go | 330 ++++++++ internal/pkg/reload/resource_type_test.go | 36 + internal/pkg/reload/service_test.go | 736 ++++++++++++++++++ internal/pkg/workload/registry_test.go | 250 ++++++ internal/pkg/workload/workload_test.go | 909 ++++++++++++++++++++++ 8 files changed, 2702 insertions(+) create mode 100644 internal/pkg/controller/filter_test.go create mode 100644 internal/pkg/controller/retry_test.go create mode 100644 internal/pkg/reload/decision_test.go create mode 100644 internal/pkg/reload/resource_type_test.go create mode 100644 internal/pkg/workload/registry_test.go diff --git a/internal/pkg/controller/filter_test.go b/internal/pkg/controller/filter_test.go new file mode 100644 index 00000000..267c2b24 --- /dev/null +++ b/internal/pkg/controller/filter_test.go @@ -0,0 +1,197 @@ +package controller + +import ( + "testing" + + "github.com/stakater/Reloader/internal/pkg/config" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/event" +) + +func TestCreateEventPredicate_CreateEvent(t *testing.T) { + tests := []struct { + name string + reloadOnCreate bool + syncAfterRestart bool + initialized bool + expectedResult bool + }{ + { + name: "reload on create enabled, initialized", + reloadOnCreate: true, + syncAfterRestart: false, + initialized: true, + expectedResult: true, + }, + { + name: "reload on create disabled, initialized", + reloadOnCreate: false, + syncAfterRestart: false, + initialized: true, + expectedResult: false, + }, + { + name: "not initialized, sync after restart enabled", + reloadOnCreate: true, + syncAfterRestart: true, + initialized: false, + expectedResult: true, + }, + { + name: "not initialized, sync after restart disabled", + reloadOnCreate: true, + syncAfterRestart: false, + initialized: false, + expectedResult: false, + }, + { + name: "not initialized, sync after restart disabled, reload on create disabled", + reloadOnCreate: false, + syncAfterRestart: false, + initialized: false, + expectedResult: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.Config{ + ReloadOnCreate: tt.reloadOnCreate, + SyncAfterRestart: tt.syncAfterRestart, + } + initialized := tt.initialized + + pred := createEventPredicate(cfg, &initialized) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + e := event.CreateEvent{Object: cm} + result := pred.Create(e) + + if result != tt.expectedResult { + t.Errorf("CreateFunc() = %v, want %v", result, tt.expectedResult) + } + }) + } +} + +func TestCreateEventPredicate_UpdateEvent(t *testing.T) { + cfg := &config.Config{} + initialized := true + + pred := createEventPredicate(cfg, &initialized) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + e := event.UpdateEvent{ObjectOld: cm, ObjectNew: cm} + result := pred.Update(e) + + // Update events should always return true + if !result { + t.Error("UpdateFunc() should always return true") + } +} + +func TestCreateEventPredicate_DeleteEvent(t *testing.T) { + tests := []struct { + name string + reloadOnDelete bool + expectedResult bool + }{ + { + name: "reload on delete enabled", + reloadOnDelete: true, + expectedResult: true, + }, + { + name: "reload on delete disabled", + reloadOnDelete: false, + expectedResult: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.Config{ + ReloadOnDelete: tt.reloadOnDelete, + } + initialized := true + + pred := createEventPredicate(cfg, &initialized) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + e := event.DeleteEvent{Object: cm} + result := pred.Delete(e) + + if result != tt.expectedResult { + t.Errorf("DeleteFunc() = %v, want %v", result, tt.expectedResult) + } + }) + } +} + +func TestCreateEventPredicate_GenericEvent(t *testing.T) { + cfg := &config.Config{} + initialized := true + + pred := createEventPredicate(cfg, &initialized) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + e := event.GenericEvent{Object: cm} + result := pred.Generic(e) + + // Generic events should always return false + if result { + t.Error("GenericFunc() should always return false") + } +} + +func TestBuildEventFilter(t *testing.T) { + cfg := &config.Config{ + ReloadOnCreate: true, + ReloadOnDelete: true, + } + initialized := true + + // Create a simple always-true predicate as the resource predicate + resourcePred := &alwaysTruePredicate{} + + filter := BuildEventFilter(resourcePred, cfg, &initialized) + + // The filter should be created without error + if filter == nil { + t.Fatal("BuildEventFilter() should return a non-nil predicate") + } + + // Test update event passes (since resourcePred returns true and update always returns true) + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + e := event.UpdateEvent{ObjectOld: cm, ObjectNew: cm} + result := filter.Update(e) + + // Since namespace filter is empty (all namespaces allowed), this should pass + if !result { + t.Error("UpdateFunc() should return true when all predicates pass") + } +} + +// alwaysTruePredicate is a helper predicate for testing +type alwaysTruePredicate struct{} + +func (p *alwaysTruePredicate) Create(_ event.CreateEvent) bool { return true } +func (p *alwaysTruePredicate) Delete(_ event.DeleteEvent) bool { return true } +func (p *alwaysTruePredicate) Update(_ event.UpdateEvent) bool { return true } +func (p *alwaysTruePredicate) Generic(_ event.GenericEvent) bool { return true } diff --git a/internal/pkg/controller/retry_test.go b/internal/pkg/controller/retry_test.go new file mode 100644 index 00000000..a3e9fc2f --- /dev/null +++ b/internal/pkg/controller/retry_test.go @@ -0,0 +1,131 @@ +package controller + +import ( + "testing" + + "github.com/stakater/Reloader/internal/pkg/reload" + "github.com/stakater/Reloader/internal/pkg/workload" + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestUpdateWorkloadWithRetry_SwitchCases(t *testing.T) { + // Test that the switch statement correctly identifies workload types + // Note: Full integration tests require a fake k8s client, so we just test type detection + + tests := []struct { + name string + workload workload.WorkloadAccessor + expectedKind workload.Kind + }{ + { + name: "deployment workload", + workload: workload.NewDeploymentWorkload(&appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + }), + expectedKind: workload.KindDeployment, + }, + { + name: "daemonset workload", + workload: workload.NewDaemonSetWorkload(&appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + }), + expectedKind: workload.KindDaemonSet, + }, + { + name: "statefulset workload", + workload: workload.NewStatefulSetWorkload(&appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + }), + expectedKind: workload.KindStatefulSet, + }, + { + name: "job workload", + workload: workload.NewJobWorkload(&batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + }), + expectedKind: workload.KindJob, + }, + { + name: "cronjob workload", + workload: workload.NewCronJobWorkload(&batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + }), + expectedKind: workload.KindCronJob, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Verify the workload kind is correctly identified + if tt.workload.Kind() != tt.expectedKind { + t.Errorf("workload.Kind() = %v, want %v", tt.workload.Kind(), tt.expectedKind) + } + }) + } +} + +func TestJobWorkloadTypeCast(t *testing.T) { + // Test that JobWorkload type cast works correctly + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "test-job", Namespace: "default"}, + } + jobWl := workload.NewJobWorkload(job) + + if jobWl.GetName() != "test-job" { + t.Errorf("JobWorkload.GetName() = %v, want test-job", jobWl.GetName()) + } + + // Test GetJob method + gotJob := jobWl.GetJob() + if gotJob.Name != "test-job" { + t.Errorf("JobWorkload.GetJob().Name = %v, want test-job", gotJob.Name) + } + + // Verify it satisfies WorkloadAccessor interface + var _ workload.WorkloadAccessor = jobWl +} + +func TestCronJobWorkloadTypeCast(t *testing.T) { + // Test that CronJobWorkload type cast works correctly + cronJob := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cronjob", Namespace: "default"}, + Spec: batchv1.CronJobSpec{ + Schedule: "*/5 * * * *", + }, + } + cronJobWl := workload.NewCronJobWorkload(cronJob) + + if cronJobWl.GetName() != "test-cronjob" { + t.Errorf("CronJobWorkload.GetName() = %v, want test-cronjob", cronJobWl.GetName()) + } + + // Test GetCronJob method + gotCronJob := cronJobWl.GetCronJob() + if gotCronJob.Name != "test-cronjob" { + t.Errorf("CronJobWorkload.GetCronJob().Name = %v, want test-cronjob", gotCronJob.Name) + } + + // Verify it satisfies WorkloadAccessor interface + var _ workload.WorkloadAccessor = cronJobWl +} + +func TestResourceTypeKind(t *testing.T) { + // Test that ResourceType.Kind() returns correct values + tests := []struct { + resourceType reload.ResourceType + expectedKind string + }{ + {reload.ResourceTypeConfigMap, "ConfigMap"}, + {reload.ResourceTypeSecret, "Secret"}, + } + + for _, tt := range tests { + t.Run(string(tt.resourceType), func(t *testing.T) { + if got := tt.resourceType.Kind(); got != tt.expectedKind { + t.Errorf("ResourceType.Kind() = %v, want %v", got, tt.expectedKind) + } + }) + } +} diff --git a/internal/pkg/reload/decision_test.go b/internal/pkg/reload/decision_test.go new file mode 100644 index 00000000..eb158d1a --- /dev/null +++ b/internal/pkg/reload/decision_test.go @@ -0,0 +1,113 @@ +package reload + +import ( + "testing" + + "github.com/stakater/Reloader/internal/pkg/workload" + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestFilterDecisions(t *testing.T) { + // Create some mock workloads for testing + wl1 := workload.NewDeploymentWorkload(&appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "deploy1", Namespace: "default"}, + }) + wl2 := workload.NewDeploymentWorkload(&appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "deploy2", Namespace: "default"}, + }) + wl3 := workload.NewDeploymentWorkload(&appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "deploy3", Namespace: "default"}, + }) + + tests := []struct { + name string + decisions []ReloadDecision + wantCount int + wantNames []string + }{ + { + name: "empty list", + decisions: []ReloadDecision{}, + wantCount: 0, + wantNames: nil, + }, + { + name: "all should reload", + decisions: []ReloadDecision{ + {Workload: wl1, ShouldReload: true, Reason: "test"}, + {Workload: wl2, ShouldReload: true, Reason: "test"}, + }, + wantCount: 2, + wantNames: []string{"deploy1", "deploy2"}, + }, + { + name: "none should reload", + decisions: []ReloadDecision{ + {Workload: wl1, ShouldReload: false, Reason: "test"}, + {Workload: wl2, ShouldReload: false, Reason: "test"}, + }, + wantCount: 0, + wantNames: nil, + }, + { + name: "mixed - some should reload", + decisions: []ReloadDecision{ + {Workload: wl1, ShouldReload: true, Reason: "test"}, + {Workload: wl2, ShouldReload: false, Reason: "test"}, + {Workload: wl3, ShouldReload: true, Reason: "test"}, + }, + wantCount: 2, + wantNames: []string{"deploy1", "deploy3"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FilterDecisions(tt.decisions) + + if len(result) != tt.wantCount { + t.Errorf("FilterDecisions() returned %d decisions, want %d", len(result), tt.wantCount) + } + + if tt.wantNames != nil { + for i, d := range result { + if d.Workload.GetName() != tt.wantNames[i] { + t.Errorf("FilterDecisions()[%d].Workload.GetName() = %s, want %s", + i, d.Workload.GetName(), tt.wantNames[i]) + } + } + } + }) + } +} + +func TestReloadDecision_Fields(t *testing.T) { + wl := workload.NewDeploymentWorkload(&appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + }) + + decision := ReloadDecision{ + Workload: wl, + ShouldReload: true, + AutoReload: true, + Reason: "test reason", + Hash: "abc123", + } + + if decision.Workload.GetName() != "test" { + t.Errorf("ReloadDecision.Workload.GetName() = %v, want test", decision.Workload.GetName()) + } + if !decision.ShouldReload { + t.Error("ReloadDecision.ShouldReload should be true") + } + if !decision.AutoReload { + t.Error("ReloadDecision.AutoReload should be true") + } + if decision.Reason != "test reason" { + t.Errorf("ReloadDecision.Reason = %v, want 'test reason'", decision.Reason) + } + if decision.Hash != "abc123" { + t.Errorf("ReloadDecision.Hash = %v, want 'abc123'", decision.Hash) + } +} diff --git a/internal/pkg/reload/predicate_test.go b/internal/pkg/reload/predicate_test.go index b2cce701..285d367b 100644 --- a/internal/pkg/reload/predicate_test.go +++ b/internal/pkg/reload/predicate_test.go @@ -607,3 +607,333 @@ func TestNamespaceFilterPredicateWithCache_NilCache(t *testing.T) { }) } } + +func TestIgnoreAnnotationPredicate_Create(t *testing.T) { + cfg := config.NewDefault() + predicate := IgnoreAnnotationPredicate(cfg) + + tests := []struct { + name string + annotations map[string]string + wantAllow bool + }{ + { + name: "no annotations", + annotations: nil, + wantAllow: true, + }, + { + name: "empty annotations", + annotations: map[string]string{}, + wantAllow: true, + }, + { + name: "other annotations only", + annotations: map[string]string{"other": "value"}, + wantAllow: true, + }, + { + name: "ignore annotation true", + annotations: map[string]string{cfg.Annotations.Ignore: "true"}, + wantAllow: false, + }, + { + name: "ignore annotation false", + annotations: map[string]string{cfg.Annotations.Ignore: "false"}, + wantAllow: true, + }, + { + name: "ignore annotation with other value", + annotations: map[string]string{cfg.Annotations.Ignore: "yes"}, + wantAllow: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + Annotations: tt.annotations, + }, + } + + e := event.CreateEvent{Object: cm} + got := predicate.Create(e) + + if got != tt.wantAllow { + t.Errorf("Create() = %v, want %v", got, tt.wantAllow) + } + }) + } +} + +func TestIgnoreAnnotationPredicate_AllEventTypes(t *testing.T) { + cfg := config.NewDefault() + predicate := IgnoreAnnotationPredicate(cfg) + + ignoredCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ignored-cm", + Namespace: "default", + Annotations: map[string]string{cfg.Annotations.Ignore: "true"}, + }, + } + + allowedCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "allowed-cm", + Namespace: "default", + }, + } + + // Test Update + if predicate.Update(event.UpdateEvent{ObjectNew: ignoredCM}) { + t.Error("Update() should block ignored resource") + } + if !predicate.Update(event.UpdateEvent{ObjectNew: allowedCM}) { + t.Error("Update() should allow non-ignored resource") + } + + // Test Delete + if predicate.Delete(event.DeleteEvent{Object: ignoredCM}) { + t.Error("Delete() should block ignored resource") + } + if !predicate.Delete(event.DeleteEvent{Object: allowedCM}) { + t.Error("Delete() should allow non-ignored resource") + } + + // Test Generic + if predicate.Generic(event.GenericEvent{Object: ignoredCM}) { + t.Error("Generic() should block ignored resource") + } + if !predicate.Generic(event.GenericEvent{Object: allowedCM}) { + t.Error("Generic() should allow non-ignored resource") + } +} + +func TestCombinedPredicates(t *testing.T) { + cfg := config.NewDefault() + cfg.IgnoredNamespaces = []string{"kube-system"} + + nsPredicate := NamespaceFilterPredicate(cfg) + ignorePredicate := IgnoreAnnotationPredicate(cfg) + + combined := CombinedPredicates(nsPredicate, ignorePredicate) + + tests := []struct { + name string + namespace string + annotations map[string]string + wantAllow bool + }{ + { + name: "both predicates pass", + namespace: "default", + annotations: nil, + wantAllow: true, + }, + { + name: "namespace predicate fails", + namespace: "kube-system", + annotations: nil, + wantAllow: false, + }, + { + name: "ignore predicate fails", + namespace: "default", + annotations: map[string]string{cfg.Annotations.Ignore: "true"}, + wantAllow: false, + }, + { + name: "both predicates fail", + namespace: "kube-system", + annotations: map[string]string{cfg.Annotations.Ignore: "true"}, + wantAllow: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: tt.namespace, + Annotations: tt.annotations, + }, + } + + e := event.CreateEvent{Object: cm} + got := combined.Create(e) + + if got != tt.wantAllow { + t.Errorf("Create() = %v, want %v", got, tt.wantAllow) + } + }) + } +} + +func TestConfigMapPredicates_Update(t *testing.T) { + cfg := config.NewDefault() + hasher := NewHasher() + predicate := ConfigMapPredicates(cfg, hasher) + + oldCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Data: map[string]string{"key": "value1"}, + } + newCMSameContent := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Data: map[string]string{"key": "value1"}, + } + newCMDifferentContent := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Data: map[string]string{"key": "value2"}, + } + + // Same content should not trigger update + e := event.UpdateEvent{ObjectOld: oldCM, ObjectNew: newCMSameContent} + if predicate.Update(e) { + t.Error("Update() should return false when content is the same") + } + + // Different content should trigger update + e = event.UpdateEvent{ObjectOld: oldCM, ObjectNew: newCMDifferentContent} + if !predicate.Update(e) { + t.Error("Update() should return true when content changed") + } +} + +func TestConfigMapPredicates_InvalidTypes(t *testing.T) { + cfg := config.NewDefault() + hasher := NewHasher() + predicate := ConfigMapPredicates(cfg, hasher) + + // Test with non-ConfigMap types + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + // Old is secret, new is configmap - should return false + e := event.UpdateEvent{ObjectOld: secret, ObjectNew: cm} + if predicate.Update(e) { + t.Error("Update() should return false for mismatched types") + } + + // Both are secrets - should return false + e = event.UpdateEvent{ObjectOld: secret, ObjectNew: secret} + if predicate.Update(e) { + t.Error("Update() should return false for non-ConfigMap types") + } +} + +func TestConfigMapPredicates_CreateDeleteGeneric(t *testing.T) { + cfg := config.NewDefault() + cfg.ReloadOnCreate = true + cfg.ReloadOnDelete = true + hasher := NewHasher() + predicate := ConfigMapPredicates(cfg, hasher) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + // Test Create + if !predicate.Create(event.CreateEvent{Object: cm}) { + t.Error("Create() should return true when ReloadOnCreate is true") + } + + // Test Delete + if !predicate.Delete(event.DeleteEvent{Object: cm}) { + t.Error("Delete() should return true when ReloadOnDelete is true") + } + + // Test Generic (should always return false) + if predicate.Generic(event.GenericEvent{Object: cm}) { + t.Error("Generic() should always return false") + } +} + +func TestSecretPredicates_Update(t *testing.T) { + cfg := config.NewDefault() + hasher := NewHasher() + predicate := SecretPredicates(cfg, hasher) + + oldSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Data: map[string][]byte{"key": []byte("value1")}, + } + newSecretSameContent := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Data: map[string][]byte{"key": []byte("value1")}, + } + newSecretDifferentContent := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Data: map[string][]byte{"key": []byte("value2")}, + } + + // Same content should not trigger update + e := event.UpdateEvent{ObjectOld: oldSecret, ObjectNew: newSecretSameContent} + if predicate.Update(e) { + t.Error("Update() should return false when content is the same") + } + + // Different content should trigger update + e = event.UpdateEvent{ObjectOld: oldSecret, ObjectNew: newSecretDifferentContent} + if !predicate.Update(e) { + t.Error("Update() should return true when content changed") + } +} + +func TestSecretPredicates_InvalidTypes(t *testing.T) { + cfg := config.NewDefault() + hasher := NewHasher() + predicate := SecretPredicates(cfg, hasher) + + // Test with non-Secret types + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + // Old is configmap, new is secret - should return false + e := event.UpdateEvent{ObjectOld: cm, ObjectNew: secret} + if predicate.Update(e) { + t.Error("Update() should return false for mismatched types") + } + + // Both are configmaps - should return false + e = event.UpdateEvent{ObjectOld: cm, ObjectNew: cm} + if predicate.Update(e) { + t.Error("Update() should return false for non-Secret types") + } +} + +func TestLabelsSet(t *testing.T) { + ls := LabelsSet{"app": "test", "env": "prod"} + + // Test Has + if !ls.Has("app") { + t.Error("Has(app) should return true") + } + if ls.Has("nonexistent") { + t.Error("Has(nonexistent) should return false") + } + + // Test Get + if ls.Get("app") != "test" { + t.Errorf("Get(app) = %v, want test", ls.Get("app")) + } + if ls.Get("env") != "prod" { + t.Errorf("Get(env) = %v, want prod", ls.Get("env")) + } + if ls.Get("nonexistent") != "" { + t.Errorf("Get(nonexistent) = %v, want empty string", ls.Get("nonexistent")) + } +} diff --git a/internal/pkg/reload/resource_type_test.go b/internal/pkg/reload/resource_type_test.go new file mode 100644 index 00000000..428f29ed --- /dev/null +++ b/internal/pkg/reload/resource_type_test.go @@ -0,0 +1,36 @@ +package reload + +import ( + "testing" +) + +func TestResourceType_Kind(t *testing.T) { + tests := []struct { + resourceType ResourceType + want string + }{ + {ResourceTypeConfigMap, "ConfigMap"}, + {ResourceTypeSecret, "Secret"}, + {ResourceType("unknown"), "unknown"}, + {ResourceType("custom"), "custom"}, + } + + for _, tt := range tests { + t.Run(string(tt.resourceType), func(t *testing.T) { + got := tt.resourceType.Kind() + if got != tt.want { + t.Errorf("ResourceType(%q).Kind() = %v, want %v", tt.resourceType, got, tt.want) + } + }) + } +} + +func TestResourceTypeConstants(t *testing.T) { + // Verify the constant values are as expected + if ResourceTypeConfigMap != "configmap" { + t.Errorf("ResourceTypeConfigMap = %v, want configmap", ResourceTypeConfigMap) + } + if ResourceTypeSecret != "secret" { + t.Errorf("ResourceTypeSecret = %v, want secret", ResourceTypeSecret) + } +} diff --git a/internal/pkg/reload/service_test.go b/internal/pkg/reload/service_test.go index 4b260a25..daea21ce 100644 --- a/internal/pkg/reload/service_test.go +++ b/internal/pkg/reload/service_test.go @@ -587,6 +587,742 @@ func TestService_ProcessConfigMap_DifferentNamespaces(t *testing.T) { } } +func TestService_Hasher(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg) + + hasher := svc.Hasher() + if hasher == nil { + t.Fatal("Expected Hasher to return non-nil hasher") + } + + // Verify it's functional + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Data: map[string]string{"key": "value"}, + } + hash := hasher.HashConfigMap(cm) + if hash == "" { + t.Error("Expected hasher to produce non-empty hash") + } +} + +func TestService_shouldProcessEvent(t *testing.T) { + tests := []struct { + name string + reloadOnCreate bool + reloadOnDelete bool + eventType EventType + expected bool + }{ + {"create enabled", true, false, EventTypeCreate, true}, + {"create disabled", false, false, EventTypeCreate, false}, + {"delete enabled", false, true, EventTypeDelete, true}, + {"delete disabled", false, false, EventTypeDelete, false}, + {"update always true", false, false, EventTypeUpdate, true}, + {"unknown event", false, false, EventType("unknown"), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := config.NewDefault() + cfg.ReloadOnCreate = tt.reloadOnCreate + cfg.ReloadOnDelete = tt.reloadOnDelete + svc := NewService(cfg) + + result := svc.shouldProcessEvent(tt.eventType) + if result != tt.expected { + t.Errorf("shouldProcessEvent(%s) = %v, want %v", tt.eventType, result, tt.expected) + } + }) + } +} + +func TestService_findVolumeUsingResource_ConfigMap(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg) + + tests := []struct { + name string + volumes []corev1.Volume + resourceName string + resourceType ResourceType + wantVolume string + }{ + { + name: "direct configmap volume", + volumes: []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-cm"}, + }, + }, + }, + }, + resourceName: "my-cm", + resourceType: ResourceTypeConfigMap, + wantVolume: "config-vol", + }, + { + name: "projected configmap volume", + volumes: []corev1.Volume{ + { + Name: "projected-vol", + VolumeSource: corev1.VolumeSource{ + Projected: &corev1.ProjectedVolumeSource{ + Sources: []corev1.VolumeProjection{ + { + ConfigMap: &corev1.ConfigMapProjection{ + LocalObjectReference: corev1.LocalObjectReference{Name: "projected-cm"}, + }, + }, + }, + }, + }, + }, + }, + resourceName: "projected-cm", + resourceType: ResourceTypeConfigMap, + wantVolume: "projected-vol", + }, + { + name: "no match", + volumes: []corev1.Volume{ + { + Name: "other-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "other-cm"}, + }, + }, + }, + }, + resourceName: "my-cm", + resourceType: ResourceTypeConfigMap, + wantVolume: "", + }, + { + name: "empty volumes", + volumes: []corev1.Volume{}, + resourceName: "my-cm", + resourceType: ResourceTypeConfigMap, + wantVolume: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := svc.findVolumeUsingResource(tt.volumes, tt.resourceName, tt.resourceType) + if got != tt.wantVolume { + t.Errorf("findVolumeUsingResource() = %q, want %q", got, tt.wantVolume) + } + }) + } +} + +func TestService_findVolumeUsingResource_Secret(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg) + + tests := []struct { + name string + volumes []corev1.Volume + resourceName string + wantVolume string + }{ + { + name: "direct secret volume", + volumes: []corev1.Volume{ + { + Name: "secret-vol", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "my-secret", + }, + }, + }, + }, + resourceName: "my-secret", + wantVolume: "secret-vol", + }, + { + name: "projected secret volume", + volumes: []corev1.Volume{ + { + Name: "projected-vol", + VolumeSource: corev1.VolumeSource{ + Projected: &corev1.ProjectedVolumeSource{ + Sources: []corev1.VolumeProjection{ + { + Secret: &corev1.SecretProjection{ + LocalObjectReference: corev1.LocalObjectReference{Name: "projected-secret"}, + }, + }, + }, + }, + }, + }, + }, + resourceName: "projected-secret", + wantVolume: "projected-vol", + }, + { + name: "no match", + volumes: []corev1.Volume{ + { + Name: "other-vol", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "other-secret", + }, + }, + }, + }, + resourceName: "my-secret", + wantVolume: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := svc.findVolumeUsingResource(tt.volumes, tt.resourceName, ResourceTypeSecret) + if got != tt.wantVolume { + t.Errorf("findVolumeUsingResource() = %q, want %q", got, tt.wantVolume) + } + }) + } +} + +func TestService_findContainerWithVolumeMount(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg) + + tests := []struct { + name string + containers []corev1.Container + volumeName string + wantName string + shouldMatch bool + }{ + { + name: "container with matching volume mount", + containers: []corev1.Container{ + { + Name: "container1", + VolumeMounts: []corev1.VolumeMount{ + {Name: "config-vol", MountPath: "/config"}, + }, + }, + }, + volumeName: "config-vol", + wantName: "container1", + shouldMatch: true, + }, + { + name: "second container with matching mount", + containers: []corev1.Container{ + { + Name: "container1", + VolumeMounts: []corev1.VolumeMount{}, + }, + { + Name: "container2", + VolumeMounts: []corev1.VolumeMount{ + {Name: "config-vol", MountPath: "/config"}, + }, + }, + }, + volumeName: "config-vol", + wantName: "container2", + shouldMatch: true, + }, + { + name: "no matching mount", + containers: []corev1.Container{ + { + Name: "container1", + VolumeMounts: []corev1.VolumeMount{ + {Name: "other-vol", MountPath: "/other"}, + }, + }, + }, + volumeName: "config-vol", + shouldMatch: false, + }, + { + name: "empty containers", + containers: []corev1.Container{}, + volumeName: "config-vol", + shouldMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := svc.findContainerWithVolumeMount(tt.containers, tt.volumeName) + if tt.shouldMatch { + if got == nil { + t.Error("Expected to find a container, got nil") + } else if got.Name != tt.wantName { + t.Errorf("findContainerWithVolumeMount() container name = %q, want %q", got.Name, tt.wantName) + } + } else { + if got != nil { + t.Errorf("Expected nil, got container %q", got.Name) + } + } + }) + } +} + +func TestService_findContainerWithEnvRef_ConfigMap(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg) + + tests := []struct { + name string + containers []corev1.Container + resourceName string + wantName string + shouldMatch bool + }{ + { + name: "container with ConfigMapKeyRef", + containers: []corev1.Container{ + { + Name: "app", + Env: []corev1.EnvVar{ + { + Name: "CONFIG_VALUE", + ValueFrom: &corev1.EnvVarSource{ + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-cm"}, + Key: "key", + }, + }, + }, + }, + }, + }, + resourceName: "my-cm", + wantName: "app", + shouldMatch: true, + }, + { + name: "container with ConfigMapRef in EnvFrom", + containers: []corev1.Container{ + { + Name: "app", + EnvFrom: []corev1.EnvFromSource{ + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-cm"}, + }, + }, + }, + }, + }, + resourceName: "my-cm", + wantName: "app", + shouldMatch: true, + }, + { + name: "no matching env ref", + containers: []corev1.Container{ + { + Name: "app", + Env: []corev1.EnvVar{ + { + Name: "SIMPLE_VAR", + Value: "value", + }, + }, + }, + }, + resourceName: "my-cm", + shouldMatch: false, + }, + { + name: "env without ValueFrom", + containers: []corev1.Container{ + { + Name: "app", + Env: []corev1.EnvVar{ + {Name: "VAR1", Value: "val"}, + }, + }, + }, + resourceName: "my-cm", + shouldMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := svc.findContainerWithEnvRef(tt.containers, tt.resourceName, ResourceTypeConfigMap) + if tt.shouldMatch { + if got == nil { + t.Error("Expected to find a container, got nil") + } else if got.Name != tt.wantName { + t.Errorf("findContainerWithEnvRef() container name = %q, want %q", got.Name, tt.wantName) + } + } else { + if got != nil { + t.Errorf("Expected nil, got container %q", got.Name) + } + } + }) + } +} + +func TestService_findContainerWithEnvRef_Secret(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg) + + tests := []struct { + name string + containers []corev1.Container + resourceName string + wantName string + shouldMatch bool + }{ + { + name: "container with SecretKeyRef", + containers: []corev1.Container{ + { + Name: "app", + Env: []corev1.EnvVar{ + { + Name: "SECRET_VALUE", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-secret"}, + Key: "password", + }, + }, + }, + }, + }, + }, + resourceName: "my-secret", + wantName: "app", + shouldMatch: true, + }, + { + name: "container with SecretRef in EnvFrom", + containers: []corev1.Container{ + { + Name: "app", + EnvFrom: []corev1.EnvFromSource{ + { + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-secret"}, + }, + }, + }, + }, + }, + resourceName: "my-secret", + wantName: "app", + shouldMatch: true, + }, + { + name: "no matching env ref", + containers: []corev1.Container{ + { + Name: "app", + Env: []corev1.EnvVar{ + { + Name: "SIMPLE_VAR", + Value: "value", + }, + }, + }, + }, + resourceName: "my-secret", + shouldMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := svc.findContainerWithEnvRef(tt.containers, tt.resourceName, ResourceTypeSecret) + if tt.shouldMatch { + if got == nil { + t.Error("Expected to find a container, got nil") + } else if got.Name != tt.wantName { + t.Errorf("findContainerWithEnvRef() container name = %q, want %q", got.Name, tt.wantName) + } + } else { + if got != nil { + t.Errorf("Expected nil, got container %q", got.Name) + } + } + }) + } +} + +func TestService_findTargetContainer_AutoReload(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg) + + // Test with autoReload=true and volume mount + deploy := createTestDeployment("test", "default", nil) + deploy.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-cm"}, + }, + }, + }, + } + deploy.Spec.Template.Spec.Containers = []corev1.Container{ + { + Name: "app", + Image: "nginx", + VolumeMounts: []corev1.VolumeMount{ + {Name: "config-vol", MountPath: "/config"}, + }, + }, + } + accessor := workload.NewDeploymentWorkload(deploy) + + container := svc.findTargetContainer(accessor, "my-cm", ResourceTypeConfigMap, true) + if container == nil { + t.Fatal("Expected to find a container") + } + if container.Name != "app" { + t.Errorf("Expected container 'app', got %q", container.Name) + } +} + +func TestService_findTargetContainer_AutoReload_EnvRef(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg) + + // Test with autoReload=true and env ref (no volume) + deploy := createTestDeployment("test", "default", nil) + deploy.Spec.Template.Spec.Containers = []corev1.Container{ + { + Name: "sidecar", + Image: "busybox", + }, + { + Name: "app", + Image: "nginx", + Env: []corev1.EnvVar{ + { + Name: "CONFIG_VAL", + ValueFrom: &corev1.EnvVarSource{ + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-cm"}, + Key: "key", + }, + }, + }, + }, + }, + } + accessor := workload.NewDeploymentWorkload(deploy) + + container := svc.findTargetContainer(accessor, "my-cm", ResourceTypeConfigMap, true) + if container == nil { + t.Fatal("Expected to find a container") + } + if container.Name != "app" { + t.Errorf("Expected container 'app', got %q", container.Name) + } +} + +func TestService_findTargetContainer_AutoReload_InitContainer(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg) + + // Test with autoReload=true where init container uses the volume + deploy := createTestDeployment("test", "default", nil) + deploy.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-cm"}, + }, + }, + }, + } + deploy.Spec.Template.Spec.InitContainers = []corev1.Container{ + { + Name: "init", + Image: "busybox", + VolumeMounts: []corev1.VolumeMount{ + {Name: "config-vol", MountPath: "/config"}, + }, + }, + } + deploy.Spec.Template.Spec.Containers = []corev1.Container{ + { + Name: "app", + Image: "nginx", + }, + } + accessor := workload.NewDeploymentWorkload(deploy) + + container := svc.findTargetContainer(accessor, "my-cm", ResourceTypeConfigMap, true) + if container == nil { + t.Fatal("Expected to find a container") + } + // Should return first main container when init container uses the volume + if container.Name != "app" { + t.Errorf("Expected container 'app', got %q", container.Name) + } +} + +func TestService_findTargetContainer_AutoReload_InitContainerEnvRef(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg) + + // Test with autoReload=true where init container has env ref + deploy := createTestDeployment("test", "default", nil) + deploy.Spec.Template.Spec.InitContainers = []corev1.Container{ + { + Name: "init", + Image: "busybox", + Env: []corev1.EnvVar{ + { + Name: "CONFIG_VAL", + ValueFrom: &corev1.EnvVarSource{ + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-cm"}, + Key: "key", + }, + }, + }, + }, + }, + } + deploy.Spec.Template.Spec.Containers = []corev1.Container{ + { + Name: "app", + Image: "nginx", + }, + } + accessor := workload.NewDeploymentWorkload(deploy) + + container := svc.findTargetContainer(accessor, "my-cm", ResourceTypeConfigMap, true) + if container == nil { + t.Fatal("Expected to find a container") + } + // Should return first main container when init container has the env ref + if container.Name != "app" { + t.Errorf("Expected container 'app', got %q", container.Name) + } +} + +func TestService_findTargetContainer_NoContainers(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg) + + deploy := createTestDeployment("test", "default", nil) + deploy.Spec.Template.Spec.Containers = []corev1.Container{} + accessor := workload.NewDeploymentWorkload(deploy) + + container := svc.findTargetContainer(accessor, "my-cm", ResourceTypeConfigMap, false) + if container != nil { + t.Error("Expected nil container for empty container list") + } +} + +func TestService_findTargetContainer_NonAutoReload(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg) + + deploy := createTestDeployment("test", "default", nil) + deploy.Spec.Template.Spec.Containers = []corev1.Container{ + {Name: "first", Image: "nginx"}, + {Name: "second", Image: "busybox"}, + } + accessor := workload.NewDeploymentWorkload(deploy) + + // Without autoReload, should return first container + container := svc.findTargetContainer(accessor, "my-cm", ResourceTypeConfigMap, false) + if container == nil { + t.Fatal("Expected to find a container") + } + if container.Name != "first" { + t.Errorf("Expected first container, got %q", container.Name) + } +} + +func TestService_findTargetContainer_AutoReload_FallbackToFirst(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg) + + // autoReload=true but no matching volume or env ref - should fallback to first container + deploy := createTestDeployment("test", "default", nil) + deploy.Spec.Template.Spec.Containers = []corev1.Container{ + {Name: "first", Image: "nginx"}, + {Name: "second", Image: "busybox"}, + } + accessor := workload.NewDeploymentWorkload(deploy) + + container := svc.findTargetContainer(accessor, "non-existent", ResourceTypeConfigMap, true) + if container == nil { + t.Fatal("Expected to find a container") + } + if container.Name != "first" { + t.Errorf("Expected first container as fallback, got %q", container.Name) + } +} + +func TestService_ProcessNilChange(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg) + + deploy := createTestDeployment("test", "default", nil) + workloads := []workload.WorkloadAccessor{workload.NewDeploymentWorkload(deploy)} + + // Test with nil ConfigMap + change := ConfigMapChange{ + ConfigMap: nil, + EventType: EventTypeUpdate, + } + + decisions := svc.Process(change, workloads) + if decisions != nil { + t.Errorf("Expected nil decisions for nil change, got %v", decisions) + } +} + +func TestService_ProcessCreateEventDisabled(t *testing.T) { + cfg := config.NewDefault() + cfg.ReloadOnCreate = false + svc := NewService(cfg) + + deploy := createTestDeployment("test", "default", map[string]string{ + "reloader.stakater.com/auto": "true", + }) + workloads := []workload.WorkloadAccessor{workload.NewDeploymentWorkload(deploy)} + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cm", Namespace: "default"}, + Data: map[string]string{"key": "value"}, + } + + change := ConfigMapChange{ + ConfigMap: cm, + EventType: EventTypeCreate, + } + + decisions := svc.Process(change, workloads) + if decisions != nil { + t.Errorf("Expected nil decisions when create events disabled, got %v", decisions) + } +} + // Helper function to create a test deployment func createTestDeployment(name, namespace string, annotations map[string]string) *appsv1.Deployment { replicas := int32(1) diff --git a/internal/pkg/workload/registry_test.go b/internal/pkg/workload/registry_test.go new file mode 100644 index 00000000..0bb47d14 --- /dev/null +++ b/internal/pkg/workload/registry_test.go @@ -0,0 +1,250 @@ +package workload + +import ( + "testing" + + argorolloutv1alpha1 "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1" + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestNewRegistry_WithoutArgoRollouts(t *testing.T) { + r := NewRegistry(false) + + kinds := r.SupportedKinds() + if len(kinds) != 5 { + t.Errorf("SupportedKinds() = %d kinds, want 5", len(kinds)) + } + + // Should not include ArgoRollout + for _, k := range kinds { + if k == KindArgoRollout { + t.Error("SupportedKinds() should not include ArgoRollout when disabled") + } + } + + // ListerFor should return nil for ArgoRollout + if r.ListerFor(KindArgoRollout) != nil { + t.Error("ListerFor(KindArgoRollout) should return nil when disabled") + } +} + +func TestNewRegistry_WithArgoRollouts(t *testing.T) { + r := NewRegistry(true) + + kinds := r.SupportedKinds() + if len(kinds) != 6 { + t.Errorf("SupportedKinds() = %d kinds, want 6", len(kinds)) + } + + // Should include ArgoRollout + found := false + for _, k := range kinds { + if k == KindArgoRollout { + found = true + break + } + } + if !found { + t.Error("SupportedKinds() should include ArgoRollout when enabled") + } + + // ListerFor should return a function for ArgoRollout + if r.ListerFor(KindArgoRollout) == nil { + t.Error("ListerFor(KindArgoRollout) should return a function when enabled") + } +} + +func TestRegistry_ListerFor_AllKinds(t *testing.T) { + r := NewRegistry(true) + + tests := []struct { + kind Kind + wantNil bool + }{ + {KindDeployment, false}, + {KindDaemonSet, false}, + {KindStatefulSet, false}, + {KindJob, false}, + {KindCronJob, false}, + {KindArgoRollout, false}, + {Kind("unknown"), true}, + } + + for _, tt := range tests { + lister := r.ListerFor(tt.kind) + if (lister == nil) != tt.wantNil { + t.Errorf("ListerFor(%s) = nil? %v, want nil? %v", tt.kind, lister == nil, tt.wantNil) + } + } +} + +func TestRegistry_FromObject_Deployment(t *testing.T) { + r := NewRegistry(false) + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + w, err := r.FromObject(deploy) + if err != nil { + t.Fatalf("FromObject(Deployment) error = %v", err) + } + if w.Kind() != KindDeployment { + t.Errorf("FromObject(Deployment).Kind() = %v, want %v", w.Kind(), KindDeployment) + } +} + +func TestRegistry_FromObject_DaemonSet(t *testing.T) { + r := NewRegistry(false) + ds := &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + w, err := r.FromObject(ds) + if err != nil { + t.Fatalf("FromObject(DaemonSet) error = %v", err) + } + if w.Kind() != KindDaemonSet { + t.Errorf("FromObject(DaemonSet).Kind() = %v, want %v", w.Kind(), KindDaemonSet) + } +} + +func TestRegistry_FromObject_StatefulSet(t *testing.T) { + r := NewRegistry(false) + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + w, err := r.FromObject(sts) + if err != nil { + t.Fatalf("FromObject(StatefulSet) error = %v", err) + } + if w.Kind() != KindStatefulSet { + t.Errorf("FromObject(StatefulSet).Kind() = %v, want %v", w.Kind(), KindStatefulSet) + } +} + +func TestRegistry_FromObject_Job(t *testing.T) { + r := NewRegistry(false) + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + w, err := r.FromObject(job) + if err != nil { + t.Fatalf("FromObject(Job) error = %v", err) + } + if w.Kind() != KindJob { + t.Errorf("FromObject(Job).Kind() = %v, want %v", w.Kind(), KindJob) + } +} + +func TestRegistry_FromObject_CronJob(t *testing.T) { + r := NewRegistry(false) + cj := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + w, err := r.FromObject(cj) + if err != nil { + t.Fatalf("FromObject(CronJob) error = %v", err) + } + if w.Kind() != KindCronJob { + t.Errorf("FromObject(CronJob).Kind() = %v, want %v", w.Kind(), KindCronJob) + } +} + +func TestRegistry_FromObject_Rollout_Enabled(t *testing.T) { + r := NewRegistry(true) + rollout := &argorolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + w, err := r.FromObject(rollout) + if err != nil { + t.Fatalf("FromObject(Rollout) error = %v", err) + } + if w.Kind() != KindArgoRollout { + t.Errorf("FromObject(Rollout).Kind() = %v, want %v", w.Kind(), KindArgoRollout) + } +} + +func TestRegistry_FromObject_Rollout_Disabled(t *testing.T) { + r := NewRegistry(false) + rollout := &argorolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + _, err := r.FromObject(rollout) + if err == nil { + t.Error("FromObject(Rollout) should return error when Argo Rollouts disabled") + } +} + +func TestRegistry_FromObject_UnsupportedType(t *testing.T) { + r := NewRegistry(false) + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + _, err := r.FromObject(cm) + if err == nil { + t.Error("FromObject(ConfigMap) should return error for unsupported type") + } +} + +func TestKindFromString(t *testing.T) { + tests := []struct { + input string + want Kind + wantErr bool + }{ + // Lowercase + {"deployment", KindDeployment, false}, + {"daemonset", KindDaemonSet, false}, + {"statefulset", KindStatefulSet, false}, + {"job", KindJob, false}, + {"cronjob", KindCronJob, false}, + {"rollout", KindArgoRollout, false}, + // Plural forms + {"deployments", KindDeployment, false}, + {"daemonsets", KindDaemonSet, false}, + {"statefulsets", KindStatefulSet, false}, + {"jobs", KindJob, false}, + {"cronjobs", KindCronJob, false}, + {"rollouts", KindArgoRollout, false}, + // Mixed case + {"Deployment", KindDeployment, false}, + {"DAEMONSET", KindDaemonSet, false}, + {"StatefulSet", KindStatefulSet, false}, + // Unknown + {"unknown", "", true}, + {"replicaset", "", true}, + {"", "", true}, + } + + for _, tt := range tests { + got, err := KindFromString(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("KindFromString(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + continue + } + if got != tt.want { + t.Errorf("KindFromString(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestNewLister(t *testing.T) { + r := NewRegistry(false) + l := NewLister(nil, r, nil) + + if l == nil { + t.Fatal("NewLister should not return nil") + } + if l.Registry != r { + t.Error("NewLister should set Registry") + } +} diff --git a/internal/pkg/workload/workload_test.go b/internal/pkg/workload/workload_test.go index b616e0c6..674f7dbc 100644 --- a/internal/pkg/workload/workload_test.go +++ b/internal/pkg/workload/workload_test.go @@ -5,6 +5,7 @@ import ( argorolloutv1alpha1 "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1" appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -597,6 +598,9 @@ func TestDaemonSetWorkload_BasicGetters(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "test-ds", Namespace: "test-ns", + Annotations: map[string]string{ + "key": "value", + }, }, } @@ -608,6 +612,128 @@ func TestDaemonSetWorkload_BasicGetters(t *testing.T) { if w.GetName() != "test-ds" { t.Errorf("GetName() = %v, want test-ds", w.GetName()) } + if w.GetNamespace() != "test-ns" { + t.Errorf("GetNamespace() = %v, want test-ns", w.GetNamespace()) + } + if w.GetAnnotations()["key"] != "value" { + t.Errorf("GetAnnotations()[key] = %v, want value", w.GetAnnotations()["key"]) + } + if w.GetObject() != ds { + t.Error("GetObject() should return the underlying daemonset") + } +} + +func TestDaemonSetWorkload_PodTemplateAnnotations(t *testing.T) { + ds := &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.DaemonSetSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "existing": "annotation", + }, + }, + }, + }, + } + + w := NewDaemonSetWorkload(ds) + + annotations := w.GetPodTemplateAnnotations() + if annotations["existing"] != "annotation" { + t.Errorf("GetPodTemplateAnnotations()[existing] = %v, want annotation", annotations["existing"]) + } + + w.SetPodTemplateAnnotation("new-key", "new-value") + if w.GetPodTemplateAnnotations()["new-key"] != "new-value" { + t.Error("SetPodTemplateAnnotation should add new annotation") + } +} + +func TestDaemonSetWorkload_PodTemplateAnnotations_NilInit(t *testing.T) { + ds := &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.DaemonSetSpec{ + Template: corev1.PodTemplateSpec{}, + }, + } + + w := NewDaemonSetWorkload(ds) + + annotations := w.GetPodTemplateAnnotations() + if annotations == nil { + t.Error("GetPodTemplateAnnotations should initialize nil map") + } + + w.SetPodTemplateAnnotation("key", "value") + if w.GetPodTemplateAnnotations()["key"] != "value" { + t.Error("SetPodTemplateAnnotation should work with nil initial map") + } +} + +func TestDaemonSetWorkload_Containers(t *testing.T) { + ds := &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.DaemonSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "main", Image: "nginx"}, + }, + InitContainers: []corev1.Container{ + {Name: "init", Image: "busybox"}, + }, + }, + }, + }, + } + + w := NewDaemonSetWorkload(ds) + + containers := w.GetContainers() + if len(containers) != 1 || containers[0].Name != "main" { + t.Errorf("GetContainers() = %v, want [main]", containers) + } + + initContainers := w.GetInitContainers() + if len(initContainers) != 1 || initContainers[0].Name != "init" { + t.Errorf("GetInitContainers() = %v, want [init]", initContainers) + } + + newContainers := []corev1.Container{{Name: "new-main", Image: "alpine"}} + w.SetContainers(newContainers) + if w.GetContainers()[0].Name != "new-main" { + t.Error("SetContainers should update containers") + } + + newInitContainers := []corev1.Container{{Name: "new-init", Image: "alpine"}} + w.SetInitContainers(newInitContainers) + if w.GetInitContainers()[0].Name != "new-init" { + t.Error("SetInitContainers should update init containers") + } +} + +func TestDaemonSetWorkload_Volumes(t *testing.T) { + ds := &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.DaemonSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{ + {Name: "config-vol"}, + {Name: "secret-vol"}, + }, + }, + }, + }, + } + + w := NewDaemonSetWorkload(ds) + + volumes := w.GetVolumes() + if len(volumes) != 2 { + t.Errorf("GetVolumes() length = %d, want 2", len(volumes)) + } } func TestDaemonSetWorkload_UsesConfigMap(t *testing.T) { @@ -638,6 +764,157 @@ func TestDaemonSetWorkload_UsesConfigMap(t *testing.T) { if !w.UsesConfigMap("ds-config") { t.Error("DaemonSet UsesConfigMap should return true for ConfigMap volume") } + if w.UsesConfigMap("other-config") { + t.Error("UsesConfigMap should return false for non-existent ConfigMap") + } +} + +func TestDaemonSetWorkload_UsesConfigMap_EnvFrom(t *testing.T) { + ds := &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.DaemonSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + EnvFrom: []corev1.EnvFromSource{ + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "ds-env-config", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + w := NewDaemonSetWorkload(ds) + + if !w.UsesConfigMap("ds-env-config") { + t.Error("DaemonSet UsesConfigMap should return true for envFrom ConfigMap") + } +} + +func TestDaemonSetWorkload_UsesSecret(t *testing.T) { + ds := &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.DaemonSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{ + { + Name: "secret-vol", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "ds-secret", + }, + }, + }, + }, + }, + }, + }, + } + + w := NewDaemonSetWorkload(ds) + + if !w.UsesSecret("ds-secret") { + t.Error("DaemonSet UsesSecret should return true for Secret volume") + } + if w.UsesSecret("other-secret") { + t.Error("UsesSecret should return false for non-existent Secret") + } +} + +func TestDaemonSetWorkload_GetEnvFromSources(t *testing.T) { + ds := &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.DaemonSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + EnvFrom: []corev1.EnvFromSource{ + {ConfigMapRef: &corev1.ConfigMapEnvSource{LocalObjectReference: corev1.LocalObjectReference{Name: "cm1"}}}, + }, + }, + }, + InitContainers: []corev1.Container{ + { + Name: "init", + EnvFrom: []corev1.EnvFromSource{ + {SecretRef: &corev1.SecretEnvSource{LocalObjectReference: corev1.LocalObjectReference{Name: "secret1"}}}, + }, + }, + }, + }, + }, + }, + } + + w := NewDaemonSetWorkload(ds) + + sources := w.GetEnvFromSources() + if len(sources) != 2 { + t.Errorf("GetEnvFromSources() returned %d sources, want 2", len(sources)) + } +} + +func TestDaemonSetWorkload_DeepCopy(t *testing.T) { + ds := &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: appsv1.DaemonSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "main", Image: "nginx"}, + }, + }, + }, + }, + } + + w := NewDaemonSetWorkload(ds) + copy := w.DeepCopy() + + w.SetPodTemplateAnnotation("modified", "true") + + copyAnnotations := copy.GetPodTemplateAnnotations() + if copyAnnotations["modified"] == "true" { + t.Error("DeepCopy should create independent copy") + } +} + +func TestDaemonSetWorkload_GetOwnerReferences(t *testing.T) { + ds := &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "apps/v1", + Kind: "DaemonSet", + Name: "test-owner", + }, + }, + }, + } + + w := NewDaemonSetWorkload(ds) + + refs := w.GetOwnerReferences() + if len(refs) != 1 || refs[0].Name != "test-owner" { + t.Errorf("GetOwnerReferences() = %v, want owner ref to test-owner", refs) + } } // StatefulSet tests @@ -646,6 +923,9 @@ func TestStatefulSetWorkload_BasicGetters(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "test-sts", Namespace: "test-ns", + Annotations: map[string]string{ + "key": "value", + }, }, } @@ -657,6 +937,193 @@ func TestStatefulSetWorkload_BasicGetters(t *testing.T) { if w.GetName() != "test-sts" { t.Errorf("GetName() = %v, want test-sts", w.GetName()) } + if w.GetNamespace() != "test-ns" { + t.Errorf("GetNamespace() = %v, want test-ns", w.GetNamespace()) + } + if w.GetAnnotations()["key"] != "value" { + t.Errorf("GetAnnotations()[key] = %v, want value", w.GetAnnotations()["key"]) + } + if w.GetObject() != sts { + t.Error("GetObject() should return the underlying statefulset") + } +} + +func TestStatefulSetWorkload_PodTemplateAnnotations(t *testing.T) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "existing": "annotation", + }, + }, + }, + }, + } + + w := NewStatefulSetWorkload(sts) + + annotations := w.GetPodTemplateAnnotations() + if annotations["existing"] != "annotation" { + t.Errorf("GetPodTemplateAnnotations()[existing] = %v, want annotation", annotations["existing"]) + } + + w.SetPodTemplateAnnotation("new-key", "new-value") + if w.GetPodTemplateAnnotations()["new-key"] != "new-value" { + t.Error("SetPodTemplateAnnotation should add new annotation") + } +} + +func TestStatefulSetWorkload_PodTemplateAnnotations_NilInit(t *testing.T) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{}, + }, + } + + w := NewStatefulSetWorkload(sts) + + annotations := w.GetPodTemplateAnnotations() + if annotations == nil { + t.Error("GetPodTemplateAnnotations should initialize nil map") + } + + w.SetPodTemplateAnnotation("key", "value") + if w.GetPodTemplateAnnotations()["key"] != "value" { + t.Error("SetPodTemplateAnnotation should work with nil initial map") + } +} + +func TestStatefulSetWorkload_Containers(t *testing.T) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "main", Image: "nginx"}, + }, + InitContainers: []corev1.Container{ + {Name: "init", Image: "busybox"}, + }, + }, + }, + }, + } + + w := NewStatefulSetWorkload(sts) + + containers := w.GetContainers() + if len(containers) != 1 || containers[0].Name != "main" { + t.Errorf("GetContainers() = %v, want [main]", containers) + } + + initContainers := w.GetInitContainers() + if len(initContainers) != 1 || initContainers[0].Name != "init" { + t.Errorf("GetInitContainers() = %v, want [init]", initContainers) + } + + newContainers := []corev1.Container{{Name: "new-main", Image: "alpine"}} + w.SetContainers(newContainers) + if w.GetContainers()[0].Name != "new-main" { + t.Error("SetContainers should update containers") + } + + newInitContainers := []corev1.Container{{Name: "new-init", Image: "alpine"}} + w.SetInitContainers(newInitContainers) + if w.GetInitContainers()[0].Name != "new-init" { + t.Error("SetInitContainers should update init containers") + } +} + +func TestStatefulSetWorkload_Volumes(t *testing.T) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{ + {Name: "config-vol"}, + {Name: "secret-vol"}, + }, + }, + }, + }, + } + + w := NewStatefulSetWorkload(sts) + + volumes := w.GetVolumes() + if len(volumes) != 2 { + t.Errorf("GetVolumes() length = %d, want 2", len(volumes)) + } +} + +func TestStatefulSetWorkload_UsesConfigMap(t *testing.T) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "sts-config", + }, + }, + }, + }, + }, + }, + }, + }, + } + + w := NewStatefulSetWorkload(sts) + + if !w.UsesConfigMap("sts-config") { + t.Error("StatefulSet UsesConfigMap should return true for ConfigMap volume") + } + if w.UsesConfigMap("other-config") { + t.Error("UsesConfigMap should return false for non-existent ConfigMap") + } +} + +func TestStatefulSetWorkload_UsesConfigMap_EnvFrom(t *testing.T) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + EnvFrom: []corev1.EnvFromSource{ + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "sts-env-config", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + w := NewStatefulSetWorkload(sts) + + if !w.UsesConfigMap("sts-env-config") { + t.Error("StatefulSet UsesConfigMap should return true for envFrom ConfigMap") + } } func TestStatefulSetWorkload_UsesSecret(t *testing.T) { @@ -685,6 +1152,126 @@ func TestStatefulSetWorkload_UsesSecret(t *testing.T) { if !w.UsesSecret("sts-secret") { t.Error("StatefulSet UsesSecret should return true for Secret volume") } + if w.UsesSecret("other-secret") { + t.Error("UsesSecret should return false for non-existent Secret") + } +} + +func TestStatefulSetWorkload_UsesSecret_EnvFrom(t *testing.T) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + EnvFrom: []corev1.EnvFromSource{ + { + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "sts-env-secret", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + w := NewStatefulSetWorkload(sts) + + if !w.UsesSecret("sts-env-secret") { + t.Error("StatefulSet UsesSecret should return true for envFrom Secret") + } +} + +func TestStatefulSetWorkload_GetEnvFromSources(t *testing.T) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + EnvFrom: []corev1.EnvFromSource{ + {ConfigMapRef: &corev1.ConfigMapEnvSource{LocalObjectReference: corev1.LocalObjectReference{Name: "cm1"}}}, + }, + }, + }, + InitContainers: []corev1.Container{ + { + Name: "init", + EnvFrom: []corev1.EnvFromSource{ + {SecretRef: &corev1.SecretEnvSource{LocalObjectReference: corev1.LocalObjectReference{Name: "secret1"}}}, + }, + }, + }, + }, + }, + }, + } + + w := NewStatefulSetWorkload(sts) + + sources := w.GetEnvFromSources() + if len(sources) != 2 { + t.Errorf("GetEnvFromSources() returned %d sources, want 2", len(sources)) + } +} + +func TestStatefulSetWorkload_DeepCopy(t *testing.T) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "main", Image: "nginx"}, + }, + }, + }, + }, + } + + w := NewStatefulSetWorkload(sts) + copy := w.DeepCopy() + + w.SetPodTemplateAnnotation("modified", "true") + + copyAnnotations := copy.GetPodTemplateAnnotations() + if copyAnnotations["modified"] == "true" { + t.Error("DeepCopy should create independent copy") + } +} + +func TestStatefulSetWorkload_GetOwnerReferences(t *testing.T) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "apps/v1", + Kind: "StatefulSet", + Name: "test-owner", + }, + }, + }, + } + + w := NewStatefulSetWorkload(sts) + + refs := w.GetOwnerReferences() + if len(refs) != 1 || refs[0].Name != "test-owner" { + t.Errorf("GetOwnerReferences() = %v, want owner ref to test-owner", refs) + } } // Test that workloads implement the interface @@ -915,3 +1502,325 @@ func TestToRolloutStrategy(t *testing.T) { } } } + +// Job tests +func TestJobWorkload_BasicGetters(t *testing.T) { + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + Namespace: "test-ns", + Annotations: map[string]string{ + "key": "value", + }, + }, + } + + w := NewJobWorkload(job) + + if w.Kind() != KindJob { + t.Errorf("Kind() = %v, want %v", w.Kind(), KindJob) + } + if w.GetName() != "test-job" { + t.Errorf("GetName() = %v, want test-job", w.GetName()) + } + if w.GetNamespace() != "test-ns" { + t.Errorf("GetNamespace() = %v, want test-ns", w.GetNamespace()) + } + if w.GetAnnotations()["key"] != "value" { + t.Errorf("GetAnnotations()[key] = %v, want value", w.GetAnnotations()["key"]) + } + if w.GetObject() != job { + t.Error("GetObject() should return the underlying job") + } +} + +func TestJobWorkload_PodTemplateAnnotations(t *testing.T) { + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "existing": "annotation", + }, + }, + }, + }, + } + + w := NewJobWorkload(job) + + annotations := w.GetPodTemplateAnnotations() + if annotations["existing"] != "annotation" { + t.Errorf("GetPodTemplateAnnotations()[existing] = %v, want annotation", annotations["existing"]) + } + + w.SetPodTemplateAnnotation("new-key", "new-value") + if w.GetPodTemplateAnnotations()["new-key"] != "new-value" { + t.Error("SetPodTemplateAnnotation should add new annotation") + } +} + +func TestJobWorkload_UsesConfigMap(t *testing.T) { + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "job-config", + }, + }, + }, + }, + }, + }, + }, + }, + } + + w := NewJobWorkload(job) + + if !w.UsesConfigMap("job-config") { + t.Error("Job UsesConfigMap should return true for ConfigMap volume") + } + if w.UsesConfigMap("other-config") { + t.Error("Job UsesConfigMap should return false for non-existent ConfigMap") + } +} + +func TestJobWorkload_UsesSecret(t *testing.T) { + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + EnvFrom: []corev1.EnvFromSource{ + { + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "job-secret", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + w := NewJobWorkload(job) + + if !w.UsesSecret("job-secret") { + t.Error("Job UsesSecret should return true for Secret envFrom") + } +} + +func TestJobWorkload_DeepCopy(t *testing.T) { + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "original": "value", + }, + }, + }, + }, + } + + w := NewJobWorkload(job) + copy := w.DeepCopy() + + w.SetPodTemplateAnnotation("modified", "true") + + copyAnnotations := copy.GetPodTemplateAnnotations() + if copyAnnotations["modified"] == "true" { + t.Error("DeepCopy should create independent copy") + } +} + +// CronJob tests +func TestCronJobWorkload_BasicGetters(t *testing.T) { + cj := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cronjob", + Namespace: "test-ns", + Annotations: map[string]string{ + "key": "value", + }, + }, + } + + w := NewCronJobWorkload(cj) + + if w.Kind() != KindCronJob { + t.Errorf("Kind() = %v, want %v", w.Kind(), KindCronJob) + } + if w.GetName() != "test-cronjob" { + t.Errorf("GetName() = %v, want test-cronjob", w.GetName()) + } + if w.GetNamespace() != "test-ns" { + t.Errorf("GetNamespace() = %v, want test-ns", w.GetNamespace()) + } + if w.GetAnnotations()["key"] != "value" { + t.Errorf("GetAnnotations()[key] = %v, want value", w.GetAnnotations()["key"]) + } + if w.GetObject() != cj { + t.Error("GetObject() should return the underlying cronjob") + } +} + +func TestCronJobWorkload_PodTemplateAnnotations(t *testing.T) { + cj := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "existing": "annotation", + }, + }, + }, + }, + }, + }, + } + + w := NewCronJobWorkload(cj) + + annotations := w.GetPodTemplateAnnotations() + if annotations["existing"] != "annotation" { + t.Errorf("GetPodTemplateAnnotations()[existing] = %v, want annotation", annotations["existing"]) + } + + w.SetPodTemplateAnnotation("new-key", "new-value") + if w.GetPodTemplateAnnotations()["new-key"] != "new-value" { + t.Error("SetPodTemplateAnnotation should add new annotation") + } +} + +func TestCronJobWorkload_UsesConfigMap(t *testing.T) { + cj := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "cronjob-config", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + w := NewCronJobWorkload(cj) + + if !w.UsesConfigMap("cronjob-config") { + t.Error("CronJob UsesConfigMap should return true for ConfigMap volume") + } + if w.UsesConfigMap("other-config") { + t.Error("CronJob UsesConfigMap should return false for non-existent ConfigMap") + } +} + +func TestCronJobWorkload_UsesSecret(t *testing.T) { + cj := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + Env: []corev1.EnvVar{ + { + Name: "SECRET_VALUE", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "cronjob-secret", + }, + Key: "key", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + w := NewCronJobWorkload(cj) + + if !w.UsesSecret("cronjob-secret") { + t.Error("CronJob UsesSecret should return true for Secret envVar") + } +} + +func TestCronJobWorkload_DeepCopy(t *testing.T) { + cj := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "original": "value", + }, + }, + }, + }, + }, + }, + } + + w := NewCronJobWorkload(cj) + copy := w.DeepCopy() + + w.SetPodTemplateAnnotation("modified", "true") + + copyAnnotations := copy.GetPodTemplateAnnotations() + if copyAnnotations["modified"] == "true" { + t.Error("DeepCopy should create independent copy") + } +} + +// Test that Job and CronJob implement the interface +func TestJobCronJobWorkloadInterface(t *testing.T) { + var _ WorkloadAccessor = (*JobWorkload)(nil) + var _ WorkloadAccessor = (*CronJobWorkload)(nil) +}