diff --git a/internal/pkg/controller/controller_test.go b/internal/pkg/controller/controller_test.go index 63e6be3e..58580c79 100644 --- a/internal/pkg/controller/controller_test.go +++ b/internal/pkg/controller/controller_test.go @@ -1,3 +1,6 @@ +//go:build integration +// +build integration + package controller import ( diff --git a/internal/pkg/controller/namespace_reconciler.go b/internal/pkg/controller/namespace_reconciler.go new file mode 100644 index 00000000..ab25fff0 --- /dev/null +++ b/internal/pkg/controller/namespace_reconciler.go @@ -0,0 +1,155 @@ +package controller + +import ( + "context" + "sync" + + "github.com/go-logr/logr" + "github.com/stakater/Reloader/internal/pkg/config" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// NamespaceCache provides thread-safe access to the set of namespaces +// that match the configured namespace label selector. +type NamespaceCache struct { + mu sync.RWMutex + namespaces map[string]struct{} + enabled bool +} + +// NewNamespaceCache creates a new NamespaceCache. +// If enabled is false, all namespace checks return true (allow all). +func NewNamespaceCache(enabled bool) *NamespaceCache { + return &NamespaceCache{ + namespaces: make(map[string]struct{}), + enabled: enabled, + } +} + +// Add adds a namespace to the cache. +func (c *NamespaceCache) Add(name string) { + c.mu.Lock() + defer c.mu.Unlock() + c.namespaces[name] = struct{}{} +} + +// Remove removes a namespace from the cache. +func (c *NamespaceCache) Remove(name string) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.namespaces, name) +} + +// Contains checks if a namespace is in the cache. +// If namespace selectors are not enabled, always returns true. +func (c *NamespaceCache) Contains(name string) bool { + if !c.enabled { + return true + } + c.mu.RLock() + defer c.mu.RUnlock() + _, ok := c.namespaces[name] + return ok +} + +// List returns a copy of all namespace names in the cache. +func (c *NamespaceCache) List() []string { + c.mu.RLock() + defer c.mu.RUnlock() + result := make([]string, 0, len(c.namespaces)) + for name := range c.namespaces { + result = append(result, name) + } + return result +} + +// IsEnabled returns whether namespace selector filtering is enabled. +func (c *NamespaceCache) IsEnabled() bool { + return c.enabled +} + +// NamespaceReconciler watches Namespace objects and maintains a cache +// of namespaces that match the configured label selector. +type NamespaceReconciler struct { + client.Client + Log logr.Logger + Config *config.Config + Cache *NamespaceCache +} + +// Reconcile handles Namespace events and updates the namespace cache. +func (r *NamespaceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := r.Log.WithValues("namespace", req.Name) + + var ns corev1.Namespace + if err := r.Get(ctx, req.NamespacedName, &ns); err != nil { + if errors.IsNotFound(err) { + // Namespace was deleted - remove from cache + r.Cache.Remove(req.Name) + log.V(1).Info("removed namespace from cache (deleted)") + return ctrl.Result{}, nil + } + log.Error(err, "failed to get Namespace") + return ctrl.Result{}, err + } + + // Check if namespace matches any of the configured selectors + if r.matchesSelectors(&ns) { + r.Cache.Add(ns.Name) + log.V(1).Info("added namespace to cache") + } else { + // Labels might have changed, remove from cache if no longer matches + r.Cache.Remove(ns.Name) + log.V(1).Info("removed namespace from cache (labels no longer match)") + } + + return ctrl.Result{}, nil +} + +// matchesSelectors checks if the namespace matches any configured label selector. +func (r *NamespaceReconciler) matchesSelectors(ns *corev1.Namespace) bool { + if len(r.Config.NamespaceSelectors) == 0 { + // No selectors configured - should not happen since reconciler is only + // set up when selectors are configured, but handle gracefully + return true + } + + nsLabels := ns.GetLabels() + if nsLabels == nil { + nsLabels = make(map[string]string) + } + + for _, selector := range r.Config.NamespaceSelectors { + if selector.Matches(nsLabelsSet(nsLabels)) { + return true + } + } + + return false +} + +// nsLabelsSet implements labels.Labels interface for a map. +type nsLabelsSet map[string]string + +func (ls nsLabelsSet) Has(key string) bool { + _, ok := ls[key] + return ok +} + +func (ls nsLabelsSet) Get(key string) string { + return ls[key] +} + +// SetupWithManager sets up the controller with the Manager. +func (r *NamespaceReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&corev1.Namespace{}). + Complete(r) +} + +// Ensure NamespaceReconciler implements reconcile.Reconciler +var _ reconcile.Reconciler = &NamespaceReconciler{} diff --git a/internal/pkg/controller/namespace_reconciler_test.go b/internal/pkg/controller/namespace_reconciler_test.go new file mode 100644 index 00000000..2ad83d1e --- /dev/null +++ b/internal/pkg/controller/namespace_reconciler_test.go @@ -0,0 +1,295 @@ +package controller_test + +import ( + "context" + "testing" + + "github.com/go-logr/logr/testr" + "github.com/stakater/Reloader/internal/pkg/config" + "github.com/stakater/Reloader/internal/pkg/controller" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestNamespaceCache_Basic(t *testing.T) { + cache := controller.NewNamespaceCache(true) + + // Test Add and Contains + cache.Add("namespace-1") + if !cache.Contains("namespace-1") { + t.Error("Cache should contain namespace-1") + } + if cache.Contains("namespace-2") { + t.Error("Cache should not contain namespace-2") + } + + // Test Remove + cache.Remove("namespace-1") + if cache.Contains("namespace-1") { + t.Error("Cache should not contain namespace-1 after removal") + } +} + +func TestNamespaceCache_Disabled(t *testing.T) { + cache := controller.NewNamespaceCache(false) + + // When disabled, Contains should always return true + if !cache.Contains("any-namespace") { + t.Error("Disabled cache should return true for any namespace") + } + if !cache.Contains("other-namespace") { + t.Error("Disabled cache should return true for any namespace") + } +} + +func TestNamespaceCache_List(t *testing.T) { + cache := controller.NewNamespaceCache(true) + cache.Add("ns-1") + cache.Add("ns-2") + cache.Add("ns-3") + + list := cache.List() + if len(list) != 3 { + t.Errorf("Expected 3 namespaces, got %d", len(list)) + } + + // Check all namespaces are in the list + found := make(map[string]bool) + for _, ns := range list { + found[ns] = true + } + for _, expected := range []string{"ns-1", "ns-2", "ns-3"} { + if !found[expected] { + t.Errorf("Expected %s in list", expected) + } + } +} + +func TestNamespaceCache_IsEnabled(t *testing.T) { + enabledCache := controller.NewNamespaceCache(true) + disabledCache := controller.NewNamespaceCache(false) + + if !enabledCache.IsEnabled() { + t.Error("EnabledCache.IsEnabled() should return true") + } + if disabledCache.IsEnabled() { + t.Error("DisabledCache.IsEnabled() should return false") + } +} + +func TestNamespaceReconciler_Add(t *testing.T) { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ns", + Labels: map[string]string{"env": "production"}, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(ns). + Build() + + cfg := config.NewDefault() + selector, _ := labels.Parse("env=production") + cfg.NamespaceSelectors = []labels.Selector{selector} + + cache := controller.NewNamespaceCache(true) + reconciler := &controller.NamespaceReconciler{ + Client: fakeClient, + Log: testr.New(t), + Config: cfg, + Cache: cache, + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-ns"}, + } + + _, err := reconciler.Reconcile(context.Background(), req) + if err != nil { + t.Fatalf("Reconcile failed: %v", err) + } + + if !cache.Contains("test-ns") { + t.Error("Cache should contain test-ns after reconcile") + } +} + +func TestNamespaceReconciler_Remove_LabelChange(t *testing.T) { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + + // Namespace with non-matching labels + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ns", + Labels: map[string]string{"env": "staging"}, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(ns). + Build() + + cfg := config.NewDefault() + selector, _ := labels.Parse("env=production") + cfg.NamespaceSelectors = []labels.Selector{selector} + + cache := controller.NewNamespaceCache(true) + // Pre-populate cache + cache.Add("test-ns") + + reconciler := &controller.NamespaceReconciler{ + Client: fakeClient, + Log: testr.New(t), + Config: cfg, + Cache: cache, + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-ns"}, + } + + _, err := reconciler.Reconcile(context.Background(), req) + if err != nil { + t.Fatalf("Reconcile failed: %v", err) + } + + if cache.Contains("test-ns") { + t.Error("Cache should not contain test-ns after reconcile (labels no longer match)") + } +} + +func TestNamespaceReconciler_Remove_Delete(t *testing.T) { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + + // No namespace in cluster (simulates delete) + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + Build() + + cfg := config.NewDefault() + selector, _ := labels.Parse("env=production") + cfg.NamespaceSelectors = []labels.Selector{selector} + + cache := controller.NewNamespaceCache(true) + // Pre-populate cache + cache.Add("deleted-ns") + + reconciler := &controller.NamespaceReconciler{ + Client: fakeClient, + Log: testr.New(t), + Config: cfg, + Cache: cache, + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "deleted-ns"}, + } + + _, err := reconciler.Reconcile(context.Background(), req) + if err != nil { + t.Fatalf("Reconcile failed: %v", err) + } + + if cache.Contains("deleted-ns") { + t.Error("Cache should not contain deleted-ns after reconcile") + } +} + +func TestNamespaceReconciler_MultipleSelectors(t *testing.T) { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ns", + Labels: map[string]string{"team": "platform"}, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(ns). + Build() + + cfg := config.NewDefault() + selector1, _ := labels.Parse("env=production") + selector2, _ := labels.Parse("team=platform") + cfg.NamespaceSelectors = []labels.Selector{selector1, selector2} + + cache := controller.NewNamespaceCache(true) + reconciler := &controller.NamespaceReconciler{ + Client: fakeClient, + Log: testr.New(t), + Config: cfg, + Cache: cache, + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-ns"}, + } + + _, err := reconciler.Reconcile(context.Background(), req) + if err != nil { + t.Fatalf("Reconcile failed: %v", err) + } + + // Should be added because it matches second selector (team=platform) + if !cache.Contains("test-ns") { + t.Error("Cache should contain test-ns (matches second selector)") + } +} + +func TestNamespaceReconciler_NoLabels(t *testing.T) { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + + // Namespace with no labels + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ns", + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(ns). + Build() + + cfg := config.NewDefault() + selector, _ := labels.Parse("env=production") + cfg.NamespaceSelectors = []labels.Selector{selector} + + cache := controller.NewNamespaceCache(true) + reconciler := &controller.NamespaceReconciler{ + Client: fakeClient, + Log: testr.New(t), + Config: cfg, + Cache: cache, + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-ns"}, + } + + _, err := reconciler.Reconcile(context.Background(), req) + if err != nil { + t.Fatalf("Reconcile failed: %v", err) + } + + if cache.Contains("test-ns") { + t.Error("Cache should not contain test-ns (no labels)") + } +} diff --git a/internal/pkg/reload/predicate.go b/internal/pkg/reload/predicate.go index 7e6381e2..030ded5d 100644 --- a/internal/pkg/reload/predicate.go +++ b/internal/pkg/reload/predicate.go @@ -72,8 +72,19 @@ func SecretPredicates(cfg *config.Config, hasher *Hasher) predicate.Predicate { } } +// NamespaceChecker defines the interface for checking if a namespace is allowed. +type NamespaceChecker interface { + Contains(name string) bool +} + // NamespaceFilterPredicate returns a predicate that filters resources by namespace. func NamespaceFilterPredicate(cfg *config.Config) predicate.Predicate { + return NamespaceFilterPredicateWithCache(cfg, nil) +} + +// NamespaceFilterPredicateWithCache returns a predicate that filters resources by namespace, +// using the provided NamespaceChecker for namespace selector filtering. +func NamespaceFilterPredicateWithCache(cfg *config.Config, nsCache NamespaceChecker) predicate.Predicate { return predicate.NewPredicateFuncs(func(obj client.Object) bool { namespace := obj.GetNamespace() @@ -82,9 +93,11 @@ func NamespaceFilterPredicate(cfg *config.Config) predicate.Predicate { return false } - // Check namespace selectors - // Note: For now, we pass through and let the controller handle selector matching - // A more efficient implementation would check labels here + // Check namespace selector cache if provided + if nsCache != nil && !nsCache.Contains(namespace) { + return false + } + return true }) } diff --git a/internal/pkg/reload/predicate_test.go b/internal/pkg/reload/predicate_test.go index 5386121e..45059002 100644 --- a/internal/pkg/reload/predicate_test.go +++ b/internal/pkg/reload/predicate_test.go @@ -500,3 +500,110 @@ func TestExistsLabelSelector(t *testing.T) { }) } } + +// mockNamespaceChecker implements NamespaceChecker for testing. +type mockNamespaceChecker struct { + allowed map[string]bool +} + +func (m *mockNamespaceChecker) Contains(name string) bool { + return m.allowed[name] +} + +func TestNamespaceFilterPredicateWithCache(t *testing.T) { + tests := []struct { + name string + ignoredNamespaces []string + cacheAllowed map[string]bool + eventNamespace string + wantAllow bool + }{ + { + name: "allowed by cache and not ignored", + ignoredNamespaces: []string{"kube-system"}, + cacheAllowed: map[string]bool{"production": true}, + eventNamespace: "production", + wantAllow: true, + }, + { + name: "blocked by cache", + ignoredNamespaces: []string{}, + cacheAllowed: map[string]bool{"production": true}, + eventNamespace: "staging", + wantAllow: false, + }, + { + name: "blocked by ignore list even if in cache", + ignoredNamespaces: []string{"kube-system"}, + cacheAllowed: map[string]bool{"kube-system": true}, + eventNamespace: "kube-system", + wantAllow: false, + }, + { + name: "ignore list checked before cache", + ignoredNamespaces: []string{"blocked-ns"}, + cacheAllowed: map[string]bool{"blocked-ns": true}, + eventNamespace: "blocked-ns", + wantAllow: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := config.NewDefault() + cfg.IgnoredNamespaces = tt.ignoredNamespaces + + cache := &mockNamespaceChecker{allowed: tt.cacheAllowed} + predicate := NamespaceFilterPredicateWithCache(cfg, cache) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: tt.eventNamespace, + }, + } + + e := event.CreateEvent{Object: cm} + got := predicate.Create(e) + + if got != tt.wantAllow { + t.Errorf("Create() = %v, want %v", got, tt.wantAllow) + } + }) + } +} + +func TestNamespaceFilterPredicateWithCache_NilCache(t *testing.T) { + cfg := config.NewDefault() + cfg.IgnoredNamespaces = []string{"kube-system"} + + // Nil cache should allow all namespaces (only check ignore list) + predicate := NamespaceFilterPredicateWithCache(cfg, nil) + + tests := []struct { + namespace string + wantAllow bool + }{ + {"default", true}, + {"production", true}, + {"kube-system", false}, // Should still respect ignore list + } + + for _, tt := range tests { + t.Run(tt.namespace, func(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: tt.namespace, + }, + } + + e := event.CreateEvent{Object: cm} + got := predicate.Create(e) + + if got != tt.wantAllow { + t.Errorf("Create() = %v, want %v for namespace %s", got, tt.wantAllow, tt.namespace) + } + }) + } +} diff --git a/internal/pkg/workload/workload_test.go b/internal/pkg/workload/workload_test.go new file mode 100644 index 00000000..b7cb2510 --- /dev/null +++ b/internal/pkg/workload/workload_test.go @@ -0,0 +1,694 @@ +package workload + +import ( + "testing" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestDeploymentWorkload_BasicGetters(t *testing.T) { + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-deploy", + Namespace: "test-ns", + Annotations: map[string]string{ + "key": "value", + }, + }, + } + + w := NewDeploymentWorkload(deploy) + + if w.Kind() != KindDeployment { + t.Errorf("Kind() = %v, want %v", w.Kind(), KindDeployment) + } + if w.GetName() != "test-deploy" { + t.Errorf("GetName() = %v, want test-deploy", 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() != deploy { + t.Error("GetObject() should return the underlying deployment") + } +} + +func TestDeploymentWorkload_PodTemplateAnnotations(t *testing.T) { + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "existing": "annotation", + }, + }, + }, + }, + } + + w := NewDeploymentWorkload(deploy) + + // Test get + annotations := w.GetPodTemplateAnnotations() + if annotations["existing"] != "annotation" { + t.Errorf("GetPodTemplateAnnotations()[existing] = %v, want annotation", annotations["existing"]) + } + + // Test set + w.SetPodTemplateAnnotation("new-key", "new-value") + if w.GetPodTemplateAnnotations()["new-key"] != "new-value" { + t.Error("SetPodTemplateAnnotation should add new annotation") + } +} + +func TestDeploymentWorkload_PodTemplateAnnotations_NilInit(t *testing.T) { + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + // No annotations set + }, + }, + } + + w := NewDeploymentWorkload(deploy) + + // Should initialize nil map + annotations := w.GetPodTemplateAnnotations() + if annotations == nil { + t.Error("GetPodTemplateAnnotations should initialize nil map") + } + + // Should work with nil initial map + w.SetPodTemplateAnnotation("key", "value") + if w.GetPodTemplateAnnotations()["key"] != "value" { + t.Error("SetPodTemplateAnnotation should work with nil initial map") + } +} + +func TestDeploymentWorkload_Containers(t *testing.T) { + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "main", Image: "nginx"}, + }, + InitContainers: []corev1.Container{ + {Name: "init", Image: "busybox"}, + }, + }, + }, + }, + } + + w := NewDeploymentWorkload(deploy) + + // Test get containers + containers := w.GetContainers() + if len(containers) != 1 || containers[0].Name != "main" { + t.Errorf("GetContainers() = %v, want [main]", containers) + } + + // Test get init containers + initContainers := w.GetInitContainers() + if len(initContainers) != 1 || initContainers[0].Name != "init" { + t.Errorf("GetInitContainers() = %v, want [init]", initContainers) + } + + // Test set containers + newContainers := []corev1.Container{{Name: "new-main", Image: "alpine"}} + w.SetContainers(newContainers) + if w.GetContainers()[0].Name != "new-main" { + t.Error("SetContainers should update containers") + } + + // Test set init 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 TestDeploymentWorkload_Volumes(t *testing.T) { + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{ + {Name: "config-vol"}, + {Name: "secret-vol"}, + }, + }, + }, + }, + } + + w := NewDeploymentWorkload(deploy) + + volumes := w.GetVolumes() + if len(volumes) != 2 { + t.Errorf("GetVolumes() length = %d, want 2", len(volumes)) + } +} + +func TestDeploymentWorkload_UsesConfigMap_Volume(t *testing.T) { + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "my-config", + }, + }, + }, + }, + }, + }, + }, + }, + } + + w := NewDeploymentWorkload(deploy) + + if !w.UsesConfigMap("my-config") { + t.Error("UsesConfigMap should return true for ConfigMap volume") + } + if w.UsesConfigMap("other-config") { + t.Error("UsesConfigMap should return false for non-existent ConfigMap") + } +} + +func TestDeploymentWorkload_UsesConfigMap_ProjectedVolume(t *testing.T) { + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{ + { + Name: "projected-vol", + VolumeSource: corev1.VolumeSource{ + Projected: &corev1.ProjectedVolumeSource{ + Sources: []corev1.VolumeProjection{ + { + ConfigMap: &corev1.ConfigMapProjection{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "projected-config", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + w := NewDeploymentWorkload(deploy) + + if !w.UsesConfigMap("projected-config") { + t.Error("UsesConfigMap should return true for projected ConfigMap volume") + } +} + +func TestDeploymentWorkload_UsesConfigMap_EnvFrom(t *testing.T) { + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + EnvFrom: []corev1.EnvFromSource{ + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "env-config", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + w := NewDeploymentWorkload(deploy) + + if !w.UsesConfigMap("env-config") { + t.Error("UsesConfigMap should return true for envFrom ConfigMap") + } +} + +func TestDeploymentWorkload_UsesConfigMap_EnvVar(t *testing.T) { + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + Env: []corev1.EnvVar{ + { + Name: "CONFIG_VALUE", + ValueFrom: &corev1.EnvVarSource{ + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "var-config", + }, + Key: "some-key", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + w := NewDeploymentWorkload(deploy) + + if !w.UsesConfigMap("var-config") { + t.Error("UsesConfigMap should return true for env var ConfigMapKeyRef") + } +} + +func TestDeploymentWorkload_UsesConfigMap_InitContainer(t *testing.T) { + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + { + Name: "init", + EnvFrom: []corev1.EnvFromSource{ + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "init-config", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + w := NewDeploymentWorkload(deploy) + + if !w.UsesConfigMap("init-config") { + t.Error("UsesConfigMap should return true for init container ConfigMap") + } +} + +func TestDeploymentWorkload_UsesSecret_Volume(t *testing.T) { + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{ + { + Name: "secret-vol", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "my-secret", + }, + }, + }, + }, + }, + }, + }, + } + + w := NewDeploymentWorkload(deploy) + + if !w.UsesSecret("my-secret") { + t.Error("UsesSecret should return true for Secret volume") + } + if w.UsesSecret("other-secret") { + t.Error("UsesSecret should return false for non-existent Secret") + } +} + +func TestDeploymentWorkload_UsesSecret_ProjectedVolume(t *testing.T) { + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{ + { + Name: "projected-vol", + VolumeSource: corev1.VolumeSource{ + Projected: &corev1.ProjectedVolumeSource{ + Sources: []corev1.VolumeProjection{ + { + Secret: &corev1.SecretProjection{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "projected-secret", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + w := NewDeploymentWorkload(deploy) + + if !w.UsesSecret("projected-secret") { + t.Error("UsesSecret should return true for projected Secret volume") + } +} + +func TestDeploymentWorkload_UsesSecret_EnvFrom(t *testing.T) { + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + EnvFrom: []corev1.EnvFromSource{ + { + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "env-secret", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + w := NewDeploymentWorkload(deploy) + + if !w.UsesSecret("env-secret") { + t.Error("UsesSecret should return true for envFrom Secret") + } +} + +func TestDeploymentWorkload_UsesSecret_EnvVar(t *testing.T) { + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.DeploymentSpec{ + 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: "var-secret", + }, + Key: "some-key", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + w := NewDeploymentWorkload(deploy) + + if !w.UsesSecret("var-secret") { + t.Error("UsesSecret should return true for env var SecretKeyRef") + } +} + +func TestDeploymentWorkload_UsesSecret_InitContainer(t *testing.T) { + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + { + Name: "init", + EnvFrom: []corev1.EnvFromSource{ + { + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "init-secret", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + w := NewDeploymentWorkload(deploy) + + if !w.UsesSecret("init-secret") { + t.Error("UsesSecret should return true for init container Secret") + } +} + +func TestDeploymentWorkload_GetEnvFromSources(t *testing.T) { + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + EnvFrom: []corev1.EnvFromSource{ + {ConfigMapRef: &corev1.ConfigMapEnvSource{LocalObjectReference: corev1.LocalObjectReference{Name: "cm1"}}}, + }, + }, + { + Name: "sidecar", + EnvFrom: []corev1.EnvFromSource{ + {SecretRef: &corev1.SecretEnvSource{LocalObjectReference: corev1.LocalObjectReference{Name: "secret1"}}}, + }, + }, + }, + InitContainers: []corev1.Container{ + { + Name: "init", + EnvFrom: []corev1.EnvFromSource{ + {ConfigMapRef: &corev1.ConfigMapEnvSource{LocalObjectReference: corev1.LocalObjectReference{Name: "init-cm"}}}, + }, + }, + }, + }, + }, + }, + } + + w := NewDeploymentWorkload(deploy) + + sources := w.GetEnvFromSources() + if len(sources) != 3 { + t.Errorf("GetEnvFromSources() returned %d sources, want 3", len(sources)) + } +} + +func TestDeploymentWorkload_DeepCopy(t *testing.T) { + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "main", Image: "nginx"}, + }, + }, + }, + }, + } + + w := NewDeploymentWorkload(deploy) + copy := w.DeepCopy() + + // Modify original + w.SetPodTemplateAnnotation("modified", "true") + + // Copy should not be affected + copyAnnotations := copy.GetPodTemplateAnnotations() + if copyAnnotations["modified"] == "true" { + t.Error("DeepCopy should create independent copy") + } +} + +func TestDeploymentWorkload_GetOwnerReferences(t *testing.T) { + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "apps/v1", + Kind: "ReplicaSet", + Name: "test-rs", + }, + }, + }, + } + + w := NewDeploymentWorkload(deploy) + + refs := w.GetOwnerReferences() + if len(refs) != 1 || refs[0].Name != "test-rs" { + t.Errorf("GetOwnerReferences() = %v, want owner ref to test-rs", refs) + } +} + +// DaemonSet tests +func TestDaemonSetWorkload_BasicGetters(t *testing.T) { + ds := &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ds", + Namespace: "test-ns", + }, + } + + w := NewDaemonSetWorkload(ds) + + if w.Kind() != KindDaemonSet { + t.Errorf("Kind() = %v, want %v", w.Kind(), KindDaemonSet) + } + if w.GetName() != "test-ds" { + t.Errorf("GetName() = %v, want test-ds", w.GetName()) + } +} + +func TestDaemonSetWorkload_UsesConfigMap(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", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "ds-config", + }, + }, + }, + }, + }, + }, + }, + }, + } + + w := NewDaemonSetWorkload(ds) + + if !w.UsesConfigMap("ds-config") { + t.Error("DaemonSet UsesConfigMap should return true for ConfigMap volume") + } +} + +// StatefulSet tests +func TestStatefulSetWorkload_BasicGetters(t *testing.T) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "test-ns", + }, + } + + w := NewStatefulSetWorkload(sts) + + if w.Kind() != KindStatefulSet { + t.Errorf("Kind() = %v, want %v", w.Kind(), KindStatefulSet) + } + if w.GetName() != "test-sts" { + t.Errorf("GetName() = %v, want test-sts", w.GetName()) + } +} + +func TestStatefulSetWorkload_UsesSecret(t *testing.T) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{ + { + Name: "secret-vol", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "sts-secret", + }, + }, + }, + }, + }, + }, + }, + } + + w := NewStatefulSetWorkload(sts) + + if !w.UsesSecret("sts-secret") { + t.Error("StatefulSet UsesSecret should return true for Secret volume") + } +} + +// Test that workloads implement the interface +func TestWorkloadInterface(t *testing.T) { + var _ WorkloadAccessor = (*DeploymentWorkload)(nil) + var _ WorkloadAccessor = (*DaemonSetWorkload)(nil) + var _ WorkloadAccessor = (*StatefulSetWorkload)(nil) +}