diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml index e4b1c6f2..3eb0f6c7 100644 --- a/.github/workflows/pull_request.yaml +++ b/.github/workflows/pull_request.yaml @@ -106,9 +106,12 @@ jobs: kubectl cluster-info - - name: Test + - name: Unit Tests run: make test + - name: E2E Tests + run: make e2e + - name: Generate Tags id: generate_tag run: | diff --git a/Makefile b/Makefile index 1f756537..57dd27b3 100644 --- a/Makefile +++ b/Makefile @@ -147,7 +147,10 @@ manifest: docker manifest annotate --arch $(ARCH) $(REPOSITORY_GENERIC) $(REPOSITORY_ARCH) test: - "$(GOCMD)" test -timeout 1800s -v ./cmd/... ./internal/... + "$(GOCMD)" test -timeout 1800s -v -short ./cmd/... ./internal/... + +e2e: + "$(GOCMD)" test -timeout 1800s -v ./test/... stop: @docker stop "${BINARY}" diff --git a/go.mod b/go.mod index 206a9d1b..eb84625c 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/go-logr/logr v1.4.2 github.com/go-logr/zerologr v1.2.3 github.com/prometheus/client_golang v1.22.0 + github.com/prometheus/client_model v0.6.2 github.com/rs/zerolog v1.34.0 github.com/spf13/cobra v1.10.1 github.com/spf13/pflag v1.0.9 @@ -43,7 +44,6 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.63.0 // indirect github.com/prometheus/procfs v0.16.0 // indirect github.com/x448/float16 v0.8.4 // indirect diff --git a/internal/pkg/alerting/alerter_test.go b/internal/pkg/alerting/alerter_test.go index 74cc95e6..c7dd2e8d 100644 --- a/internal/pkg/alerting/alerter_test.go +++ b/internal/pkg/alerting/alerter_test.go @@ -45,9 +45,9 @@ func testAlertMessage() AlertMessage { func TestNewAlerter(t *testing.T) { tests := []struct { - name string - setup func(*config.Config) - wantType string + name string + setup func(*config.Config) + wantType string }{ { name: "disabled", diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index c833328f..0bb972d9 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -26,75 +26,75 @@ const ( // Config holds all configuration for Reloader. type Config struct { - Annotations AnnotationConfig - AutoReloadAll bool - ReloadStrategy ReloadStrategy - ArgoRolloutsEnabled bool - ArgoRolloutStrategy ArgoRolloutStrategy - ReloadOnCreate bool - ReloadOnDelete bool - SyncAfterRestart bool - EnableHA bool - WebhookURL string + Annotations AnnotationConfig `json:"annotations"` + AutoReloadAll bool `json:"autoReloadAll"` + ReloadStrategy ReloadStrategy `json:"reloadStrategy"` + ArgoRolloutsEnabled bool `json:"argoRolloutsEnabled"` + ArgoRolloutStrategy ArgoRolloutStrategy `json:"argoRolloutStrategy"` + ReloadOnCreate bool `json:"reloadOnCreate"` + ReloadOnDelete bool `json:"reloadOnDelete"` + SyncAfterRestart bool `json:"syncAfterRestart"` + EnableHA bool `json:"enableHA"` + WebhookURL string `json:"webhookUrl,omitempty"` - IgnoredResources []string - IgnoredWorkloads []string - IgnoredNamespaces []string - NamespaceSelectors []labels.Selector - ResourceSelectors []labels.Selector - NamespaceSelectorStrings []string - ResourceSelectorStrings []string + IgnoredResources []string `json:"ignoredResources,omitempty"` + IgnoredWorkloads []string `json:"ignoredWorkloads,omitempty"` + IgnoredNamespaces []string `json:"ignoredNamespaces,omitempty"` + NamespaceSelectors []labels.Selector `json:"-"` + ResourceSelectors []labels.Selector `json:"-"` + NamespaceSelectorStrings []string `json:"namespaceSelectors,omitempty"` + ResourceSelectorStrings []string `json:"resourceSelectors,omitempty"` - LogFormat string - LogLevel string - MetricsAddr string - HealthAddr string - EnablePProf bool - PProfAddr string + LogFormat string `json:"logFormat,omitempty"` + LogLevel string `json:"logLevel"` + MetricsAddr string `json:"metricsAddr"` + HealthAddr string `json:"healthAddr"` + EnablePProf bool `json:"enablePProf"` + PProfAddr string `json:"pprofAddr,omitempty"` - Alerting AlertingConfig - LeaderElection LeaderElectionConfig - WatchedNamespace string - SyncPeriod time.Duration + Alerting AlertingConfig `json:"alerting"` + LeaderElection LeaderElectionConfig `json:"leaderElection"` + WatchedNamespace string `json:"watchedNamespace,omitempty"` + SyncPeriod time.Duration `json:"syncPeriod"` } // AnnotationConfig holds customizable annotation keys. type AnnotationConfig struct { - Prefix string - Auto string - ConfigmapAuto string - SecretAuto string - ConfigmapReload string - SecretReload string - ConfigmapExclude string - SecretExclude string - Ignore string - Search string - Match string - RolloutStrategy string - PausePeriod string - PausedAt string - LastReloadedFrom string + Prefix string `json:"prefix"` + Auto string `json:"auto"` + ConfigmapAuto string `json:"configmapAuto"` + SecretAuto string `json:"secretAuto"` + ConfigmapReload string `json:"configmapReload"` + SecretReload string `json:"secretReload"` + ConfigmapExclude string `json:"configmapExclude"` + SecretExclude string `json:"secretExclude"` + Ignore string `json:"ignore"` + Search string `json:"search"` + Match string `json:"match"` + RolloutStrategy string `json:"rolloutStrategy"` + PausePeriod string `json:"pausePeriod"` + PausedAt string `json:"pausedAt"` + LastReloadedFrom string `json:"lastReloadedFrom"` } // AlertingConfig holds configuration for alerting integrations. type AlertingConfig struct { - Enabled bool - WebhookURL string - Sink string - Proxy string - Additional string + Enabled bool `json:"enabled"` + WebhookURL string `json:"webhookUrl,omitempty"` + Sink string `json:"sink,omitempty"` + Proxy string `json:"proxy,omitempty"` + Additional string `json:"additional,omitempty"` } // LeaderElectionConfig holds configuration for leader election. type LeaderElectionConfig struct { - LockName string - Namespace string - Identity string - LeaseDuration time.Duration - RenewDeadline time.Duration - RetryPeriod time.Duration - ReleaseOnCancel bool + LockName string `json:"lockName"` + Namespace string `json:"namespace,omitempty"` + Identity string `json:"identity,omitempty"` + LeaseDuration time.Duration `json:"leaseDuration"` + RenewDeadline time.Duration `json:"renewDeadline"` + RetryPeriod time.Duration `json:"retryPeriod"` + ReleaseOnCancel bool `json:"releaseOnCancel"` } // NewDefault creates a Config with default values. @@ -184,4 +184,3 @@ func (c *Config) IsNamespaceIgnored(namespace string) bool { } return false } - diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go index 17a3e33d..5ec7f758 100644 --- a/internal/pkg/config/config_test.go +++ b/internal/pkg/config/config_test.go @@ -12,7 +12,6 @@ func TestNewDefault(t *testing.T) { t.Fatal("NewDefault() returned nil") } - // Test default values if cfg.ReloadStrategy != ReloadStrategyEnvVars { t.Errorf("ReloadStrategy = %v, want %v", cfg.ReloadStrategy, ReloadStrategyEnvVars) } @@ -200,4 +199,3 @@ func TestConfig_IsNamespaceIgnored(t *testing.T) { ) } } - diff --git a/internal/pkg/config/flags.go b/internal/pkg/config/flags.go index 1de5d2ef..e30be2ff 100644 --- a/internal/pkg/config/flags.go +++ b/internal/pkg/config/flags.go @@ -208,7 +208,7 @@ func ApplyFlags(cfg *Config) error { cfg.IgnoredWorkloads = splitAndTrim(fv.ignoredWorkloads) cfg.IgnoredNamespaces = splitAndTrim(fv.ignoredNamespaces) - // Store raw selector strings (for backward compatibility) + // Store raw selector strings cfg.NamespaceSelectorStrings = splitAndTrim(fv.namespaceSelectors) cfg.ResourceSelectorStrings = splitAndTrim(fv.resourceSelectors) diff --git a/internal/pkg/config/flags_test.go b/internal/pkg/config/flags_test.go index 4ddcbaeb..06638d6b 100644 --- a/internal/pkg/config/flags_test.go +++ b/internal/pkg/config/flags_test.go @@ -12,7 +12,6 @@ func TestBindFlags(t *testing.T) { BindFlags(fs, cfg) - // Verify flags are registered expectedFlags := []string{ "auto-reload-all", "reload-strategy", @@ -64,12 +63,10 @@ func TestBindFlags_DefaultValues(t *testing.T) { BindFlags(fs, cfg) - // Parse empty args to use defaults if err := fs.Parse([]string{}); err != nil { t.Fatalf("Parse() error = %v", err) } - // Check default values are preserved if cfg.ReloadStrategy != ReloadStrategyEnvVars { t.Errorf("ReloadStrategy = %v, want %v", cfg.ReloadStrategy, ReloadStrategyEnvVars) } @@ -146,33 +143,33 @@ func TestApplyFlags_BooleanStrings(t *testing.T) { } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Reset flag values - fv = flagValues{} + t.Run( + tt.name, func(t *testing.T) { + fv = flagValues{} - cfg := NewDefault() - fs := pflag.NewFlagSet("test", pflag.ContinueOnError) - BindFlags(fs, cfg) + cfg := NewDefault() + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + BindFlags(fs, cfg) - if err := fs.Parse(tt.args); err != nil { - t.Fatalf("Parse() error = %v", err) - } + if err := fs.Parse(tt.args); err != nil { + t.Fatalf("Parse() error = %v", err) + } - err := ApplyFlags(cfg) - if (err != nil) != tt.wantErr { - t.Errorf("ApplyFlags() error = %v, wantErr %v", err, tt.wantErr) - return - } + err := ApplyFlags(cfg) + if (err != nil) != tt.wantErr { + t.Errorf("ApplyFlags() error = %v, wantErr %v", err, tt.wantErr) + return + } - if cfg.ArgoRolloutsEnabled != tt.want { - t.Errorf("ArgoRolloutsEnabled = %v, want %v", cfg.ArgoRolloutsEnabled, tt.want) - } - }) + if cfg.ArgoRolloutsEnabled != tt.want { + t.Errorf("ArgoRolloutsEnabled = %v, want %v", cfg.ArgoRolloutsEnabled, tt.want) + } + }, + ) } } func TestApplyFlags_CommaSeparatedLists(t *testing.T) { - // Reset flag values fv = flagValues{} cfg := NewDefault() @@ -193,7 +190,6 @@ func TestApplyFlags_CommaSeparatedLists(t *testing.T) { t.Fatalf("ApplyFlags() error = %v", err) } - // Check ignored resources if len(cfg.IgnoredResources) != 2 { t.Errorf("IgnoredResources length = %d, want 2", len(cfg.IgnoredResources)) } @@ -201,7 +197,6 @@ func TestApplyFlags_CommaSeparatedLists(t *testing.T) { t.Errorf("IgnoredResources = %v", cfg.IgnoredResources) } - // Check ignored workloads if len(cfg.IgnoredWorkloads) != 2 { t.Errorf("IgnoredWorkloads length = %d, want 2", len(cfg.IgnoredWorkloads)) } @@ -213,7 +208,6 @@ func TestApplyFlags_CommaSeparatedLists(t *testing.T) { } func TestApplyFlags_Selectors(t *testing.T) { - // Reset flag values fv = flagValues{} cfg := NewDefault() @@ -241,14 +235,12 @@ func TestApplyFlags_Selectors(t *testing.T) { t.Errorf("ResourceSelectors length = %d, want 1", len(cfg.ResourceSelectors)) } - // Check string versions are preserved if len(cfg.NamespaceSelectorStrings) != 2 { t.Errorf("NamespaceSelectorStrings length = %d, want 2", len(cfg.NamespaceSelectorStrings)) } } func TestApplyFlags_InvalidSelector(t *testing.T) { - // Reset flag values fv = flagValues{} cfg := NewDefault() @@ -290,12 +282,14 @@ func TestParseBoolString(t *testing.T) { } for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - got := parseBoolString(tt.input) - if got != tt.want { - t.Errorf("parseBoolString(%q) = %v, want %v", tt.input, got, tt.want) - } - }) + t.Run( + tt.input, func(t *testing.T) { + got := parseBoolString(tt.input) + if got != tt.want { + t.Errorf("parseBoolString(%q) = %v, want %v", tt.input, got, tt.want) + } + }, + ) } } @@ -314,17 +308,19 @@ func TestSplitAndTrim(t *testing.T) { } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := splitAndTrim(tt.input) - if len(got) != len(tt.want) { - t.Errorf("splitAndTrim(%q) length = %d, want %d", tt.input, len(got), len(tt.want)) - return - } - for i := range got { - if got[i] != tt.want[i] { - t.Errorf("splitAndTrim(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i]) + t.Run( + tt.name, func(t *testing.T) { + got := splitAndTrim(tt.input) + if len(got) != len(tt.want) { + t.Errorf("splitAndTrim(%q) length = %d, want %d", tt.input, len(got), len(tt.want)) + return } - } - }) + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("splitAndTrim(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i]) + } + } + }, + ) } } diff --git a/internal/pkg/config/validation.go b/internal/pkg/config/validation.go index 161102a0..85aa0c77 100644 --- a/internal/pkg/config/validation.go +++ b/internal/pkg/config/validation.go @@ -66,7 +66,7 @@ func (c *Config) Validate() error { default: errs = append( errs, ValidationError{ - Field: "ArgoRolloutStrategy", + Field: "ArgoRolloutStrategy", Message: fmt.Sprintf( "invalid value %q, must be %q or %q", c.ArgoRolloutStrategy, ArgoRolloutStrategyRestart, ArgoRolloutStrategyRollout, ), diff --git a/internal/pkg/constants/constants.go b/internal/pkg/constants/constants.go deleted file mode 100644 index c1dc81a0..00000000 --- a/internal/pkg/constants/constants.go +++ /dev/null @@ -1,7 +0,0 @@ -package constants - -// Environment variable names for pod identity in HA mode. -const ( - PodNameEnv string = "POD_NAME" - PodNamespaceEnv string = "POD_NAMESPACE" -) diff --git a/internal/pkg/controller/configmap_reconciler_test.go b/internal/pkg/controller/configmap_reconciler_test.go index cd4e8d35..ad457d98 100644 --- a/internal/pkg/controller/configmap_reconciler_test.go +++ b/internal/pkg/controller/configmap_reconciler_test.go @@ -1,844 +1,159 @@ package controller_test import ( - "context" "testing" - "github.com/go-logr/logr/testr" - "github.com/stakater/Reloader/internal/pkg/alerting" "github.com/stakater/Reloader/internal/pkg/config" - "github.com/stakater/Reloader/internal/pkg/controller" - "github.com/stakater/Reloader/internal/pkg/events" - "github.com/stakater/Reloader/internal/pkg/metrics" - "github.com/stakater/Reloader/internal/pkg/reload" - "github.com/stakater/Reloader/internal/pkg/webhook" - "github.com/stakater/Reloader/internal/pkg/workload" - 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" - "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 newTestScheme() *runtime.Scheme { - scheme := runtime.NewScheme() - _ = corev1.AddToScheme(scheme) - _ = appsv1.AddToScheme(scheme) - _ = batchv1.AddToScheme(scheme) - return scheme -} - -func newTestConfigMapReconciler(t *testing.T, cfg *config.Config, objects ...runtime.Object) *controller.ConfigMapReconciler { - scheme := newTestScheme() - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithRuntimeObjects(objects...). - Build() - - collectors := metrics.NewCollectors() - - return &controller.ConfigMapReconciler{ - Client: fakeClient, - Log: testr.New(t), - Config: cfg, - ReloadService: reload.NewService(cfg), - Registry: workload.NewRegistry(cfg.ArgoRolloutsEnabled), - Collectors: &collectors, - EventRecorder: events.NewRecorder(nil), - WebhookClient: webhook.NewClient("", testr.New(t)), - Alerter: &alerting.NoOpAlerter{}, - } -} - func TestConfigMapReconciler_NotFound(t *testing.T) { cfg := config.NewDefault() - reconciler := newTestConfigMapReconciler(t, cfg) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "nonexistent-cm", - Namespace: "default", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue for NotFound") - } + reconciler := newConfigMapReconciler(t, cfg) + assertReconcileSuccess(t, reconciler, reconcileRequest("nonexistent-cm", "default")) } func TestConfigMapReconciler_NotFound_ReloadOnDelete(t *testing.T) { cfg := config.NewDefault() cfg.ReloadOnDelete = true - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment", - Namespace: "default", - Annotations: map[string]string{ - cfg.Annotations.ConfigmapReload: "deleted-cm", - }, - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - }}, - }, - }, - }, - } - - reconciler := newTestConfigMapReconciler(t, cfg, deployment) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "deleted-cm", - Namespace: "default", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue") - } + deployment := testDeployment("test-deployment", "default", map[string]string{ + cfg.Annotations.ConfigmapReload: "deleted-cm", + }) + reconciler := newConfigMapReconciler(t, cfg, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("deleted-cm", "default")) } func TestConfigMapReconciler_IgnoredNamespace(t *testing.T) { cfg := config.NewDefault() cfg.IgnoredNamespaces = []string{"kube-system"} - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "kube-system", - }, - Data: map[string]string{"key": "value"}, - } - - reconciler := newTestConfigMapReconciler(t, cfg, cm) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "test-cm", - Namespace: "kube-system", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue for ignored namespace") - } + cm := testConfigMap("test-cm", "kube-system") + reconciler := newConfigMapReconciler(t, cfg, cm) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-cm", "kube-system")) } func TestConfigMapReconciler_NoMatchingWorkloads(t *testing.T) { cfg := config.NewDefault() - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "default", - }, - Data: map[string]string{"key": "value"}, - } - - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment", - Namespace: "default", - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - }}, - }, - }, - }, - } - - reconciler := newTestConfigMapReconciler(t, cfg, cm, deployment) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "test-cm", - Namespace: "default", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue") - } + cm := testConfigMap("test-cm", "default") + deployment := testDeployment("test-deployment", "default", nil) + reconciler := newConfigMapReconciler(t, cfg, cm, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-cm", "default")) } func TestConfigMapReconciler_MatchingDeployment_AutoAnnotation(t *testing.T) { cfg := config.NewDefault() cfg.AutoReloadAll = true - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "default", - }, - Data: map[string]string{"key": "value"}, - } - - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment", - Namespace: "default", - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - EnvFrom: []corev1.EnvFromSource{{ - ConfigMapRef: &corev1.ConfigMapEnvSource{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "test-cm", - }, - }, - }}, - }}, - }, - }, - }, - } - - reconciler := newTestConfigMapReconciler(t, cfg, cm, deployment) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "test-cm", - Namespace: "default", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue") - } + cm := testConfigMap("test-cm", "default") + deployment := testDeploymentWithEnvFrom("test-deployment", "default", "test-cm", "") + reconciler := newConfigMapReconciler(t, cfg, cm, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-cm", "default")) } func TestConfigMapReconciler_MatchingDeployment_ExplicitAnnotation(t *testing.T) { cfg := config.NewDefault() - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "default", - }, - Data: map[string]string{"key": "value"}, - } - - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment", - Namespace: "default", - Annotations: map[string]string{ - cfg.Annotations.ConfigmapReload: "test-cm", - }, - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - }}, - }, - }, - }, - } - - reconciler := newTestConfigMapReconciler(t, cfg, cm, deployment) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "test-cm", - Namespace: "default", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue") - } + cm := testConfigMap("test-cm", "default") + deployment := testDeployment("test-deployment", "default", map[string]string{ + cfg.Annotations.ConfigmapReload: "test-cm", + }) + reconciler := newConfigMapReconciler(t, cfg, cm, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-cm", "default")) } func TestConfigMapReconciler_WorkloadInDifferentNamespace(t *testing.T) { cfg := config.NewDefault() - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "namespace-a", - }, - Data: map[string]string{"key": "value"}, - } - - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment", - Namespace: "namespace-b", - Annotations: map[string]string{ - cfg.Annotations.ConfigmapReload: "test-cm", - }, - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - }}, - }, - }, - }, - } - - reconciler := newTestConfigMapReconciler(t, cfg, cm, deployment) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "test-cm", - Namespace: "namespace-a", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue") - } + cm := testConfigMap("test-cm", "namespace-a") + deployment := testDeployment("test-deployment", "namespace-b", map[string]string{ + cfg.Annotations.ConfigmapReload: "test-cm", + }) + reconciler := newConfigMapReconciler(t, cfg, cm, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-cm", "namespace-a")) } func TestConfigMapReconciler_IgnoredWorkloadType(t *testing.T) { cfg := config.NewDefault() cfg.IgnoredWorkloads = []string{"deployment"} - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "default", - }, - Data: map[string]string{"key": "value"}, - } - - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment", - Namespace: "default", - Annotations: map[string]string{ - cfg.Annotations.ConfigmapReload: "test-cm", - }, - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - }}, - }, - }, - }, - } - - reconciler := newTestConfigMapReconciler(t, cfg, cm, deployment) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "test-cm", - Namespace: "default", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue") - } + cm := testConfigMap("test-cm", "default") + deployment := testDeployment("test-deployment", "default", map[string]string{ + cfg.Annotations.ConfigmapReload: "test-cm", + }) + reconciler := newConfigMapReconciler(t, cfg, cm, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-cm", "default")) } func TestConfigMapReconciler_DaemonSet(t *testing.T) { cfg := config.NewDefault() - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "default", - }, - Data: map[string]string{"key": "value"}, - } - - daemonset := &appsv1.DaemonSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-daemonset", - Namespace: "default", - Annotations: map[string]string{ - cfg.Annotations.ConfigmapReload: "test-cm", - }, - }, - Spec: appsv1.DaemonSetSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - }}, - }, - }, - }, - } - - reconciler := newTestConfigMapReconciler(t, cfg, cm, daemonset) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "test-cm", - Namespace: "default", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue") - } + cm := testConfigMap("test-cm", "default") + daemonset := testDaemonSet("test-daemonset", "default", map[string]string{ + cfg.Annotations.ConfigmapReload: "test-cm", + }) + reconciler := newConfigMapReconciler(t, cfg, cm, daemonset) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-cm", "default")) } func TestConfigMapReconciler_StatefulSet(t *testing.T) { cfg := config.NewDefault() - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "default", - }, - Data: map[string]string{"key": "value"}, - } - - statefulset := &appsv1.StatefulSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-statefulset", - Namespace: "default", - Annotations: map[string]string{ - cfg.Annotations.ConfigmapReload: "test-cm", - }, - }, - Spec: appsv1.StatefulSetSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - }}, - }, - }, - }, - } - - reconciler := newTestConfigMapReconciler(t, cfg, cm, statefulset) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "test-cm", - Namespace: "default", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue") - } + cm := testConfigMap("test-cm", "default") + statefulset := testStatefulSet("test-statefulset", "default", map[string]string{ + cfg.Annotations.ConfigmapReload: "test-cm", + }) + reconciler := newConfigMapReconciler(t, cfg, cm, statefulset) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-cm", "default")) } func TestConfigMapReconciler_MultipleWorkloads(t *testing.T) { cfg := config.NewDefault() - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "shared-cm", - Namespace: "default", - }, - Data: map[string]string{"key": "value"}, - } + cm := testConfigMap("shared-cm", "default") + deployment1 := testDeployment("deployment-1", "default", map[string]string{ + cfg.Annotations.ConfigmapReload: "shared-cm", + }) + deployment2 := testDeployment("deployment-2", "default", map[string]string{ + cfg.Annotations.ConfigmapReload: "shared-cm", + }) + daemonset := testDaemonSet("daemonset-1", "default", map[string]string{ + cfg.Annotations.ConfigmapReload: "shared-cm", + }) - deployment1 := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "deployment-1", - Namespace: "default", - Annotations: map[string]string{ - cfg.Annotations.ConfigmapReload: "shared-cm", - }, - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test1"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test1"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - }}, - }, - }, - }, - } - - deployment2 := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "deployment-2", - Namespace: "default", - Annotations: map[string]string{ - cfg.Annotations.ConfigmapReload: "shared-cm", - }, - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test2"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test2"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - }}, - }, - }, - }, - } - - daemonset := &appsv1.DaemonSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: "daemonset-1", - Namespace: "default", - Annotations: map[string]string{ - cfg.Annotations.ConfigmapReload: "shared-cm", - }, - }, - Spec: appsv1.DaemonSetSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "daemon"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "daemon"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - }}, - }, - }, - }, - } - - reconciler := newTestConfigMapReconciler(t, cfg, cm, deployment1, deployment2, daemonset) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "shared-cm", - Namespace: "default", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue") - } + reconciler := newConfigMapReconciler(t, cfg, cm, deployment1, deployment2, daemonset) + assertReconcileSuccess(t, reconciler, reconcileRequest("shared-cm", "default")) } func TestConfigMapReconciler_VolumeMount(t *testing.T) { cfg := config.NewDefault() cfg.AutoReloadAll = true - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "volume-cm", - Namespace: "default", - }, - Data: map[string]string{"config.yaml": "key: value"}, - } - - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment", - Namespace: "default", - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - VolumeMounts: []corev1.VolumeMount{{ - Name: "config", - MountPath: "/etc/config", - }}, - }}, - Volumes: []corev1.Volume{{ - Name: "config", - VolumeSource: corev1.VolumeSource{ - ConfigMap: &corev1.ConfigMapVolumeSource{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "volume-cm", - }, - }, - }, - }}, - }, - }, - }, - } - - reconciler := newTestConfigMapReconciler(t, cfg, cm, deployment) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "volume-cm", - Namespace: "default", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue") - } + cm := testConfigMap("volume-cm", "default") + deployment := testDeploymentWithVolume("test-deployment", "default", "volume-cm", "") + reconciler := newConfigMapReconciler(t, cfg, cm, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("volume-cm", "default")) } func TestConfigMapReconciler_ProjectedVolume(t *testing.T) { cfg := config.NewDefault() cfg.AutoReloadAll = true - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "projected-cm", - Namespace: "default", - }, - Data: map[string]string{"config.yaml": "key: value"}, - } - - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment", - Namespace: "default", - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - VolumeMounts: []corev1.VolumeMount{{ - Name: "config", - MountPath: "/etc/config", - }}, - }}, - Volumes: []corev1.Volume{{ - Name: "config", - VolumeSource: corev1.VolumeSource{ - Projected: &corev1.ProjectedVolumeSource{ - Sources: []corev1.VolumeProjection{{ - ConfigMap: &corev1.ConfigMapProjection{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "projected-cm", - }, - }, - }}, - }, - }, - }}, - }, - }, - }, - } - - reconciler := newTestConfigMapReconciler(t, cfg, cm, deployment) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "projected-cm", - Namespace: "default", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue") - } + cm := testConfigMap("projected-cm", "default") + deployment := testDeploymentWithProjectedVolume("test-deployment", "default", "projected-cm", "") + reconciler := newConfigMapReconciler(t, cfg, cm, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("projected-cm", "default")) } func TestConfigMapReconciler_SearchAnnotation(t *testing.T) { cfg := config.NewDefault() - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "default", - Annotations: map[string]string{ - cfg.Annotations.Match: "true", - }, - }, - Data: map[string]string{"key": "value"}, - } - - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment", - Namespace: "default", - Annotations: map[string]string{ - cfg.Annotations.Search: "true", - }, - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - }}, - }, - }, - }, - } - - reconciler := newTestConfigMapReconciler(t, cfg, cm, deployment) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "test-cm", - Namespace: "default", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue") - } + cm := testConfigMapWithAnnotations("test-cm", "default", map[string]string{ + cfg.Annotations.Match: "true", + }) + deployment := testDeployment("test-deployment", "default", map[string]string{ + cfg.Annotations.Search: "true", + }) + reconciler := newConfigMapReconciler(t, cfg, cm, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-cm", "default")) } diff --git a/internal/pkg/controller/filter_test.go b/internal/pkg/controller/filter_test.go index 267c2b24..0d551e64 100644 --- a/internal/pkg/controller/filter_test.go +++ b/internal/pkg/controller/filter_test.go @@ -11,70 +11,72 @@ import ( func TestCreateEventPredicate_CreateEvent(t *testing.T) { tests := []struct { - name string - reloadOnCreate bool - syncAfterRestart bool - initialized bool - expectedResult bool + 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 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: "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 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", + 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, + 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 + t.Run( + tt.name, func(t *testing.T) { + cfg := &config.Config{ + ReloadOnCreate: tt.reloadOnCreate, + SyncAfterRestart: tt.syncAfterRestart, + } + initialized := tt.initialized - pred := createEventPredicate(cfg, &initialized) + pred := createEventPredicate(cfg, &initialized) - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, - } + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } - e := event.CreateEvent{Object: cm} - result := pred.Create(e) + e := event.CreateEvent{Object: cm} + result := pred.Create(e) - if result != tt.expectedResult { - t.Errorf("CreateFunc() = %v, want %v", result, tt.expectedResult) - } - }) + if result != tt.expectedResult { + t.Errorf("CreateFunc() = %v, want %v", result, tt.expectedResult) + } + }, + ) } } @@ -91,7 +93,6 @@ func TestCreateEventPredicate_UpdateEvent(t *testing.T) { 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") } @@ -116,25 +117,27 @@ func TestCreateEventPredicate_DeleteEvent(t *testing.T) { } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := &config.Config{ - ReloadOnDelete: tt.reloadOnDelete, - } - initialized := true + t.Run( + tt.name, func(t *testing.T) { + cfg := &config.Config{ + ReloadOnDelete: tt.reloadOnDelete, + } + initialized := true - pred := createEventPredicate(cfg, &initialized) + pred := createEventPredicate(cfg, &initialized) - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, - } + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } - e := event.DeleteEvent{Object: cm} - result := pred.Delete(e) + e := event.DeleteEvent{Object: cm} + result := pred.Delete(e) - if result != tt.expectedResult { - t.Errorf("DeleteFunc() = %v, want %v", result, tt.expectedResult) - } - }) + if result != tt.expectedResult { + t.Errorf("DeleteFunc() = %v, want %v", result, tt.expectedResult) + } + }, + ) } } @@ -151,7 +154,6 @@ func TestCreateEventPredicate_GenericEvent(t *testing.T) { e := event.GenericEvent{Object: cm} result := pred.Generic(e) - // Generic events should always return false if result { t.Error("GenericFunc() should always return false") } @@ -164,17 +166,14 @@ func TestBuildEventFilter(t *testing.T) { } 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"}, } @@ -182,7 +181,6 @@ func TestBuildEventFilter(t *testing.T) { 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") } diff --git a/internal/pkg/controller/manager.go b/internal/pkg/controller/manager.go index 2c887340..bc83ca70 100644 --- a/internal/pkg/controller/manager.go +++ b/internal/pkg/controller/manager.go @@ -16,6 +16,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/healthz" ctrlmetrics "sigs.k8s.io/controller-runtime/pkg/metrics/server" @@ -81,6 +82,37 @@ func NewManager(opts ManagerOptions) (ctrl.Manager, error) { return mgr, nil } +// NewManagerWithRestConfig creates a new controller-runtime manager with the given rest.Config. +// This is useful for testing where you have a pre-existing cluster configuration. +func NewManagerWithRestConfig(opts ManagerOptions, restConfig *rest.Config) (ctrl.Manager, error) { + cfg := opts.Config + le := cfg.LeaderElection + + mgrOpts := ctrl.Options{ + Scheme: runtimeScheme, + Metrics: ctrlmetrics.Options{ + BindAddress: "0", // Disable metrics server in tests + }, + HealthProbeBindAddress: "0", // Disable health probes in tests + + // Leader election configuration + LeaderElection: cfg.EnableHA, + LeaderElectionID: le.LockName, + LeaderElectionNamespace: le.Namespace, + LeaderElectionReleaseOnCancel: le.ReleaseOnCancel, + LeaseDuration: &le.LeaseDuration, + RenewDeadline: &le.RenewDeadline, + RetryPeriod: &le.RetryPeriod, + } + + mgr, err := ctrl.NewManager(restConfig, mgrOpts) + if err != nil { + return nil, fmt.Errorf("creating manager: %w", err) + } + + return mgr, nil +} + // SetupReconcilers sets up all reconcilers with the manager. func SetupReconcilers(mgr ctrl.Manager, cfg *config.Config, log logr.Logger, collectors *metrics.Collectors) error { registry := workload.NewRegistry(cfg.ArgoRolloutsEnabled) diff --git a/internal/pkg/controller/namespace_reconciler_test.go b/internal/pkg/controller/namespace_reconciler_test.go index 2ad83d1e..3d4fcc65 100644 --- a/internal/pkg/controller/namespace_reconciler_test.go +++ b/internal/pkg/controller/namespace_reconciler_test.go @@ -1,25 +1,16 @@ 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") @@ -28,7 +19,6 @@ func TestNamespaceCache_Basic(t *testing.T) { 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") @@ -38,13 +28,9 @@ func TestNamespaceCache_Basic(t *testing.T) { 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) { @@ -58,7 +44,6 @@ func TestNamespaceCache_List(t *testing.T) { 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 @@ -71,53 +56,24 @@ func TestNamespaceCache_List(t *testing.T) { } func TestNamespaceCache_IsEnabled(t *testing.T) { - enabledCache := controller.NewNamespaceCache(true) - disabledCache := controller.NewNamespaceCache(false) - - if !enabledCache.IsEnabled() { + if !controller.NewNamespaceCache(true).IsEnabled() { t.Error("EnabledCache.IsEnabled() should return true") } - if disabledCache.IsEnabled() { + if controller.NewNamespaceCache(false).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, - } + ns := testNamespace("test-ns", map[string]string{"env": "production"}) + reconciler := newNamespaceReconciler(t, cfg, cache, ns) - req := ctrl.Request{ - NamespacedName: types.NamespacedName{Name: "test-ns"}, - } - - _, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } + assertReconcileSuccess(t, reconciler, namespaceRequest("test-ns")) if !cache.Contains("test-ns") { t.Error("Cache should contain test-ns after reconcile") @@ -125,45 +81,17 @@ func TestNamespaceReconciler_Add(t *testing.T) { } 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") + cache.Add("test-ns") // Pre-populate - reconciler := &controller.NamespaceReconciler{ - Client: fakeClient, - Log: testr.New(t), - Config: cfg, - Cache: cache, - } + ns := testNamespace("test-ns", map[string]string{"env": "staging"}) // Non-matching + reconciler := newNamespaceReconciler(t, cfg, cache, ns) - req := ctrl.Request{ - NamespacedName: types.NamespacedName{Name: "test-ns"}, - } - - _, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } + assertReconcileSuccess(t, reconciler, namespaceRequest("test-ns")) if cache.Contains("test-ns") { t.Error("Cache should not contain test-ns after reconcile (labels no longer match)") @@ -171,37 +99,16 @@ func TestNamespaceReconciler_Remove_LabelChange(t *testing.T) { } 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") + cache.Add("deleted-ns") // Pre-populate - reconciler := &controller.NamespaceReconciler{ - Client: fakeClient, - Log: testr.New(t), - Config: cfg, - Cache: cache, - } + reconciler := newNamespaceReconciler(t, cfg, cache) // No namespace in cluster - req := ctrl.Request{ - NamespacedName: types.NamespacedName{Name: "deleted-ns"}, - } - - _, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } + assertReconcileSuccess(t, reconciler, namespaceRequest("deleted-ns")) if cache.Contains("deleted-ns") { t.Error("Cache should not contain deleted-ns after reconcile") @@ -209,85 +116,32 @@ func TestNamespaceReconciler_Remove_Delete(t *testing.T) { } 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, - } + ns := testNamespace("test-ns", map[string]string{"team": "platform"}) + reconciler := newNamespaceReconciler(t, cfg, cache, ns) - req := ctrl.Request{ - NamespacedName: types.NamespacedName{Name: "test-ns"}, - } + assertReconcileSuccess(t, reconciler, namespaceRequest("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, - } + ns := testNamespace("test-ns", nil) // No labels + reconciler := newNamespaceReconciler(t, cfg, cache, ns) - req := ctrl.Request{ - NamespacedName: types.NamespacedName{Name: "test-ns"}, - } - - _, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } + assertReconcileSuccess(t, reconciler, namespaceRequest("test-ns")) if cache.Contains("test-ns") { t.Error("Cache should not contain test-ns (no labels)") diff --git a/internal/pkg/controller/retry_test.go b/internal/pkg/controller/retry_test.go index a3e9fc2f..a6237586 100644 --- a/internal/pkg/controller/retry_test.go +++ b/internal/pkg/controller/retry_test.go @@ -1,118 +1,286 @@ -package controller +package controller_test import ( + "context" "testing" + "github.com/stakater/Reloader/internal/pkg/config" + "github.com/stakater/Reloader/internal/pkg/controller" "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" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" ) -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 - +func TestUpdateWorkloadWithRetry_WorkloadTypes(t *testing.T) { tests := []struct { name string - workload workload.WorkloadAccessor - expectedKind workload.Kind + object runtime.Object + workload func(runtime.Object) workload.WorkloadAccessor + resourceType reload.ResourceType + verify func(t *testing.T, c client.Client) }{ { - name: "deployment workload", - workload: workload.NewDeploymentWorkload(&appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, - }), - expectedKind: workload.KindDeployment, + name: "Deployment", + object: testDeployment("test-deployment", "default", nil), + workload: func(o runtime.Object) workload.WorkloadAccessor { + return workload.NewDeploymentWorkload(o.(*appsv1.Deployment)) + }, + resourceType: reload.ResourceTypeConfigMap, + verify: func(t *testing.T, c client.Client) { + var result appsv1.Deployment + if err := c.Get(context.Background(), types.NamespacedName{Name: "test-deployment", Namespace: "default"}, &result); err != nil { + t.Fatalf("Failed to get deployment: %v", err) + } + if result.Spec.Template.Annotations == nil { + t.Fatal("Expected pod template annotations to be set") + } + }, }, { - name: "daemonset workload", - workload: workload.NewDaemonSetWorkload(&appsv1.DaemonSet{ - ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, - }), - expectedKind: workload.KindDaemonSet, + name: "DaemonSet", + object: testDaemonSet("test-daemonset", "default", nil), + workload: func(o runtime.Object) workload.WorkloadAccessor { + return workload.NewDaemonSetWorkload(o.(*appsv1.DaemonSet)) + }, + resourceType: reload.ResourceTypeSecret, + verify: func(t *testing.T, c client.Client) { + var result appsv1.DaemonSet + if err := c.Get(context.Background(), types.NamespacedName{Name: "test-daemonset", Namespace: "default"}, &result); err != nil { + t.Fatalf("Failed to get daemonset: %v", err) + } + if result.Spec.Template.Annotations == nil { + t.Fatal("Expected pod template annotations to be set") + } + }, }, { - name: "statefulset workload", - workload: workload.NewStatefulSetWorkload(&appsv1.StatefulSet{ - ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, - }), - expectedKind: workload.KindStatefulSet, + name: "StatefulSet", + object: testStatefulSet("test-statefulset", "default", nil), + workload: func(o runtime.Object) workload.WorkloadAccessor { + return workload.NewStatefulSetWorkload(o.(*appsv1.StatefulSet)) + }, + resourceType: reload.ResourceTypeConfigMap, + verify: func(t *testing.T, c client.Client) { + var result appsv1.StatefulSet + if err := c.Get(context.Background(), types.NamespacedName{Name: "test-statefulset", Namespace: "default"}, &result); err != nil { + t.Fatalf("Failed to get statefulset: %v", err) + } + if result.Spec.Template.Annotations == nil { + t.Fatal("Expected pod template annotations to be set") + } + }, }, { - name: "job workload", - workload: workload.NewJobWorkload(&batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, - }), - expectedKind: workload.KindJob, + name: "Job", + object: testJob("test-job", "default"), + workload: func(o runtime.Object) workload.WorkloadAccessor { + return workload.NewJobWorkload(o.(*batchv1.Job)) + }, + resourceType: reload.ResourceTypeConfigMap, + verify: func(t *testing.T, c client.Client) { + var jobs batchv1.JobList + if err := c.List(context.Background(), &jobs, client.InNamespace("default")); err != nil { + t.Fatalf("Failed to list jobs: %v", err) + } + if len(jobs.Items) != 1 { + t.Errorf("Expected 1 job (recreated), got %d", len(jobs.Items)) + } + }, }, { - name: "cronjob workload", - workload: workload.NewCronJobWorkload(&batchv1.CronJob{ - ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, - }), - expectedKind: workload.KindCronJob, + name: "CronJob", + object: testCronJob("test-cronjob", "default"), + workload: func(o runtime.Object) workload.WorkloadAccessor { + return workload.NewCronJobWorkload(o.(*batchv1.CronJob)) + }, + resourceType: reload.ResourceTypeSecret, + verify: func(t *testing.T, c client.Client) { + var jobs batchv1.JobList + if err := c.List(context.Background(), &jobs, client.InNamespace("default")); err != nil { + t.Fatalf("Failed to list jobs: %v", err) + } + if len(jobs.Items) != 1 { + t.Errorf("Expected 1 job from cronjob, got %d", len(jobs.Items)) + } + if len(jobs.Items) > 0 && jobs.Items[0].Annotations["cronjob.kubernetes.io/instantiate"] != "manual" { + t.Error("Expected job to have manual instantiate annotation") + } + }, }, } 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) - } - }) + t.Run( + tt.name, func(t *testing.T) { + cfg := config.NewDefault() + reloadService := reload.NewService(cfg) + + fakeClient := fake.NewClientBuilder(). + WithScheme(testScheme()). + WithRuntimeObjects(tt.object). + Build() + + wl := tt.workload(tt.object) + + updated, err := controller.UpdateWorkloadWithRetry( + context.Background(), + fakeClient, + reloadService, + wl, + "test-resource", + tt.resourceType, + "default", + "abc123", + false, + ) + + if err != nil { + t.Fatalf("UpdateWorkloadWithRetry failed: %v", err) + } + if !updated { + t.Error("Expected workload to be updated") + } + + tt.verify(t, fakeClient) + }, + ) } } -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 * * * *", +func TestUpdateWorkloadWithRetry_Strategies(t *testing.T) { + tests := []struct { + name string + strategy config.ReloadStrategy + verify func(t *testing.T, cfg *config.Config, result *appsv1.Deployment) + }{ + { + name: "EnvVarStrategy", + strategy: config.ReloadStrategyEnvVars, + verify: func(t *testing.T, cfg *config.Config, result *appsv1.Deployment) { + found := false + for _, env := range result.Spec.Template.Spec.Containers[0].Env { + if env.Name == "STAKATER_TEST_CM_CONFIGMAP" && env.Value == "abc123" { + found = true + break + } + } + if !found { + t.Error("Expected STAKATER_TEST_CM_CONFIGMAP env var to be set") + } + }, + }, + { + name: "AnnotationStrategy", + strategy: config.ReloadStrategyAnnotations, + verify: func(t *testing.T, cfg *config.Config, result *appsv1.Deployment) { + if result.Spec.Template.Annotations == nil { + t.Fatal("Expected pod template annotations to be set") + } + if _, ok := result.Spec.Template.Annotations[cfg.Annotations.LastReloadedFrom]; !ok { + t.Errorf("Expected %s annotation to be set", cfg.Annotations.LastReloadedFrom) + } + for _, env := range result.Spec.Template.Spec.Containers[0].Env { + if env.Name == "STAKATER_TEST_CM_CONFIGMAP" { + t.Error("Annotation strategy should not add env vars") + } + } + }, }, } - cronJobWl := workload.NewCronJobWorkload(cronJob) - if cronJobWl.GetName() != "test-cronjob" { - t.Errorf("CronJobWorkload.GetName() = %v, want test-cronjob", cronJobWl.GetName()) + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + cfg := config.NewDefault() + cfg.ReloadStrategy = tt.strategy + reloadService := reload.NewService(cfg) + + deployment := testDeployment("test-deployment", "default", nil) + fakeClient := fake.NewClientBuilder(). + WithScheme(testScheme()). + WithObjects(deployment). + Build() + + wl := workload.NewDeploymentWorkload(deployment) + + updated, err := controller.UpdateWorkloadWithRetry( + context.Background(), + fakeClient, + reloadService, + wl, + "test-cm", + reload.ResourceTypeConfigMap, + "default", + "abc123", + false, + ) + + if err != nil { + t.Fatalf("UpdateWorkloadWithRetry failed: %v", err) + } + if !updated { + t.Error("Expected workload to be updated") + } + + var result appsv1.Deployment + if err := fakeClient.Get( + context.Background(), types.NamespacedName{Name: "test-deployment", Namespace: "default"}, &result, + ); err != nil { + t.Fatalf("Failed to get deployment: %v", err) + } + + tt.verify(t, cfg, &result) + }, + ) + } +} + +func TestUpdateWorkloadWithRetry_NoUpdate(t *testing.T) { + cfg := config.NewDefault() + reloadService := reload.NewService(cfg) + + deployment := testDeployment("test-deployment", "default", nil) + deployment.Spec.Template.Spec.Containers[0].Env = []corev1.EnvVar{ + { + Name: "STAKATER_TEST_CM_CONFIGMAP", + Value: "abc123", + }, } - // Test GetCronJob method - gotCronJob := cronJobWl.GetCronJob() - if gotCronJob.Name != "test-cronjob" { - t.Errorf("CronJobWorkload.GetCronJob().Name = %v, want test-cronjob", gotCronJob.Name) - } + fakeClient := fake.NewClientBuilder(). + WithScheme(testScheme()). + WithObjects(deployment). + Build() - // Verify it satisfies WorkloadAccessor interface - var _ workload.WorkloadAccessor = cronJobWl + wl := workload.NewDeploymentWorkload(deployment) + + updated, err := controller.UpdateWorkloadWithRetry( + context.Background(), + fakeClient, + reloadService, + wl, + "test-cm", + reload.ResourceTypeConfigMap, + "default", + "abc123", // Same hash as already set + false, + ) + + if err != nil { + t.Fatalf("UpdateWorkloadWithRetry failed: %v", err) + } + if updated { + t.Error("Expected workload NOT to be updated (same hash)") + } } func TestResourceTypeKind(t *testing.T) { - // Test that ResourceType.Kind() returns correct values tests := []struct { resourceType reload.ResourceType expectedKind string @@ -122,10 +290,12 @@ func TestResourceTypeKind(t *testing.T) { } 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) - } - }) + 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/controller/secret_reconciler_test.go b/internal/pkg/controller/secret_reconciler_test.go index 155324aa..11a879b4 100644 --- a/internal/pkg/controller/secret_reconciler_test.go +++ b/internal/pkg/controller/secret_reconciler_test.go @@ -1,1017 +1,172 @@ package controller_test import ( - "context" "testing" - "github.com/go-logr/logr/testr" - "github.com/stakater/Reloader/internal/pkg/alerting" "github.com/stakater/Reloader/internal/pkg/config" - "github.com/stakater/Reloader/internal/pkg/controller" - "github.com/stakater/Reloader/internal/pkg/events" - "github.com/stakater/Reloader/internal/pkg/metrics" - "github.com/stakater/Reloader/internal/pkg/reload" - "github.com/stakater/Reloader/internal/pkg/webhook" - "github.com/stakater/Reloader/internal/pkg/workload" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "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 newTestSecretReconciler(t *testing.T, cfg *config.Config, objects ...runtime.Object) *controller.SecretReconciler { - scheme := newTestScheme() - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithRuntimeObjects(objects...). - Build() - - collectors := metrics.NewCollectors() - - return &controller.SecretReconciler{ - Client: fakeClient, - Log: testr.New(t), - Config: cfg, - ReloadService: reload.NewService(cfg), - Registry: workload.NewRegistry(cfg.ArgoRolloutsEnabled), - Collectors: &collectors, - EventRecorder: events.NewRecorder(nil), - WebhookClient: webhook.NewClient("", testr.New(t)), - Alerter: &alerting.NoOpAlerter{}, - } -} - func TestSecretReconciler_NotFound(t *testing.T) { cfg := config.NewDefault() - reconciler := newTestSecretReconciler(t, cfg) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "nonexistent-secret", - Namespace: "default", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue for NotFound") - } + reconciler := newSecretReconciler(t, cfg) + assertReconcileSuccess(t, reconciler, reconcileRequest("nonexistent-secret", "default")) } func TestSecretReconciler_NotFound_ReloadOnDelete(t *testing.T) { cfg := config.NewDefault() cfg.ReloadOnDelete = true - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment", - Namespace: "default", - Annotations: map[string]string{ - cfg.Annotations.SecretReload: "deleted-secret", - }, - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - }}, - }, - }, - }, - } - - reconciler := newTestSecretReconciler(t, cfg, deployment) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "deleted-secret", - Namespace: "default", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue") - } + deployment := testDeployment("test-deployment", "default", map[string]string{ + cfg.Annotations.SecretReload: "deleted-secret", + }) + reconciler := newSecretReconciler(t, cfg, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("deleted-secret", "default")) } func TestSecretReconciler_IgnoredNamespace(t *testing.T) { cfg := config.NewDefault() cfg.IgnoredNamespaces = []string{"kube-system"} - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "kube-system", - }, - Data: map[string][]byte{"key": []byte("value")}, - } - - reconciler := newTestSecretReconciler(t, cfg, secret) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "test-secret", - Namespace: "kube-system", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue for ignored namespace") - } + secret := testSecret("test-secret", "kube-system") + reconciler := newSecretReconciler(t, cfg, secret) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-secret", "kube-system")) } func TestSecretReconciler_NoMatchingWorkloads(t *testing.T) { cfg := config.NewDefault() - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "default", - }, - Data: map[string][]byte{"key": []byte("value")}, - } - - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment", - Namespace: "default", - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - }}, - }, - }, - }, - } - - reconciler := newTestSecretReconciler(t, cfg, secret, deployment) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "test-secret", - Namespace: "default", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue") - } + secret := testSecret("test-secret", "default") + deployment := testDeployment("test-deployment", "default", nil) + reconciler := newSecretReconciler(t, cfg, secret, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-secret", "default")) } func TestSecretReconciler_MatchingDeployment_AutoAnnotation(t *testing.T) { cfg := config.NewDefault() cfg.AutoReloadAll = true - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "default", - }, - Data: map[string][]byte{"password": []byte("secret123")}, - } - - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment", - Namespace: "default", - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - EnvFrom: []corev1.EnvFromSource{{ - SecretRef: &corev1.SecretEnvSource{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "test-secret", - }, - }, - }}, - }}, - }, - }, - }, - } - - reconciler := newTestSecretReconciler(t, cfg, secret, deployment) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "test-secret", - Namespace: "default", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue") - } + secret := testSecret("test-secret", "default") + deployment := testDeploymentWithEnvFrom("test-deployment", "default", "", "test-secret") + reconciler := newSecretReconciler(t, cfg, secret, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-secret", "default")) } func TestSecretReconciler_MatchingDeployment_ExplicitAnnotation(t *testing.T) { cfg := config.NewDefault() - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "default", - }, - Data: map[string][]byte{"password": []byte("secret123")}, - } - - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment", - Namespace: "default", - Annotations: map[string]string{ - cfg.Annotations.SecretReload: "test-secret", - }, - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - }}, - }, - }, - }, - } - - reconciler := newTestSecretReconciler(t, cfg, secret, deployment) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "test-secret", - Namespace: "default", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue") - } + secret := testSecret("test-secret", "default") + deployment := testDeployment("test-deployment", "default", map[string]string{ + cfg.Annotations.SecretReload: "test-secret", + }) + reconciler := newSecretReconciler(t, cfg, secret, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-secret", "default")) } func TestSecretReconciler_WorkloadInDifferentNamespace(t *testing.T) { cfg := config.NewDefault() - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "namespace-a", - }, - Data: map[string][]byte{"key": []byte("value")}, - } - - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment", - Namespace: "namespace-b", - Annotations: map[string]string{ - cfg.Annotations.SecretReload: "test-secret", - }, - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - }}, - }, - }, - }, - } - - reconciler := newTestSecretReconciler(t, cfg, secret, deployment) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "test-secret", - Namespace: "namespace-a", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue") - } + secret := testSecret("test-secret", "namespace-a") + deployment := testDeployment("test-deployment", "namespace-b", map[string]string{ + cfg.Annotations.SecretReload: "test-secret", + }) + reconciler := newSecretReconciler(t, cfg, secret, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-secret", "namespace-a")) } func TestSecretReconciler_IgnoredWorkloadType(t *testing.T) { cfg := config.NewDefault() cfg.IgnoredWorkloads = []string{"deployment"} - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "default", - }, - Data: map[string][]byte{"key": []byte("value")}, - } - - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment", - Namespace: "default", - Annotations: map[string]string{ - cfg.Annotations.SecretReload: "test-secret", - }, - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - }}, - }, - }, - }, - } - - reconciler := newTestSecretReconciler(t, cfg, secret, deployment) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "test-secret", - Namespace: "default", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue") - } + secret := testSecret("test-secret", "default") + deployment := testDeployment("test-deployment", "default", map[string]string{ + cfg.Annotations.SecretReload: "test-secret", + }) + reconciler := newSecretReconciler(t, cfg, secret, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-secret", "default")) } func TestSecretReconciler_DaemonSet(t *testing.T) { cfg := config.NewDefault() - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "default", - }, - Data: map[string][]byte{"key": []byte("value")}, - } - - daemonset := &appsv1.DaemonSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-daemonset", - Namespace: "default", - Annotations: map[string]string{ - cfg.Annotations.SecretReload: "test-secret", - }, - }, - Spec: appsv1.DaemonSetSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - }}, - }, - }, - }, - } - - reconciler := newTestSecretReconciler(t, cfg, secret, daemonset) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "test-secret", - Namespace: "default", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue") - } + secret := testSecret("test-secret", "default") + daemonset := testDaemonSet("test-daemonset", "default", map[string]string{ + cfg.Annotations.SecretReload: "test-secret", + }) + reconciler := newSecretReconciler(t, cfg, secret, daemonset) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-secret", "default")) } func TestSecretReconciler_StatefulSet(t *testing.T) { cfg := config.NewDefault() - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "default", - }, - Data: map[string][]byte{"key": []byte("value")}, - } + secret := testSecret("test-secret", "default") + statefulset := testStatefulSet("test-statefulset", "default", map[string]string{ + cfg.Annotations.SecretReload: "test-secret", + }) + reconciler := newSecretReconciler(t, cfg, secret, statefulset) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-secret", "default")) +} - statefulset := &appsv1.StatefulSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-statefulset", - Namespace: "default", - Annotations: map[string]string{ - cfg.Annotations.SecretReload: "test-secret", - }, - }, - Spec: appsv1.StatefulSetSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - }}, - }, - }, - }, - } +func TestSecretReconciler_MultipleWorkloads(t *testing.T) { + cfg := config.NewDefault() - reconciler := newTestSecretReconciler(t, cfg, secret, statefulset) + secret := testSecret("shared-secret", "default") + deployment1 := testDeployment("deployment-1", "default", map[string]string{ + cfg.Annotations.SecretReload: "shared-secret", + }) + deployment2 := testDeployment("deployment-2", "default", map[string]string{ + cfg.Annotations.SecretReload: "shared-secret", + }) + daemonset := testDaemonSet("daemonset-1", "default", map[string]string{ + cfg.Annotations.SecretReload: "shared-secret", + }) - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "test-secret", - Namespace: "default", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue") - } + reconciler := newSecretReconciler(t, cfg, secret, deployment1, deployment2, daemonset) + assertReconcileSuccess(t, reconciler, reconcileRequest("shared-secret", "default")) } func TestSecretReconciler_VolumeMount(t *testing.T) { cfg := config.NewDefault() cfg.AutoReloadAll = true - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "volume-secret", - Namespace: "default", - }, - Data: map[string][]byte{"credentials": []byte("supersecret")}, - } - - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment", - Namespace: "default", - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - VolumeMounts: []corev1.VolumeMount{{ - Name: "secrets", - MountPath: "/etc/secrets", - }}, - }}, - Volumes: []corev1.Volume{{ - Name: "secrets", - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - SecretName: "volume-secret", - }, - }, - }}, - }, - }, - }, - } - - reconciler := newTestSecretReconciler(t, cfg, secret, deployment) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "volume-secret", - Namespace: "default", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue") - } + secret := testSecret("volume-secret", "default") + deployment := testDeploymentWithVolume("test-deployment", "default", "", "volume-secret") + reconciler := newSecretReconciler(t, cfg, secret, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("volume-secret", "default")) } func TestSecretReconciler_ProjectedVolume(t *testing.T) { cfg := config.NewDefault() cfg.AutoReloadAll = true - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "projected-secret", - Namespace: "default", - }, - Data: map[string][]byte{"credentials": []byte("supersecret")}, - } - - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment", - Namespace: "default", - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - VolumeMounts: []corev1.VolumeMount{{ - Name: "secrets", - MountPath: "/etc/secrets", - }}, - }}, - Volumes: []corev1.Volume{{ - Name: "secrets", - VolumeSource: corev1.VolumeSource{ - Projected: &corev1.ProjectedVolumeSource{ - Sources: []corev1.VolumeProjection{{ - Secret: &corev1.SecretProjection{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "projected-secret", - }, - }, - }}, - }, - }, - }}, - }, - }, - }, - } - - reconciler := newTestSecretReconciler(t, cfg, secret, deployment) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "projected-secret", - Namespace: "default", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue") - } -} - -func TestSecretReconciler_EnvKeyRef(t *testing.T) { - cfg := config.NewDefault() - cfg.AutoReloadAll = true - - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "key-ref-secret", - Namespace: "default", - }, - Data: map[string][]byte{"password": []byte("secret123")}, - } - - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment", - Namespace: "default", - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - Env: []corev1.EnvVar{{ - Name: "DB_PASSWORD", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "key-ref-secret", - }, - Key: "password", - }, - }, - }}, - }}, - }, - }, - }, - } - - reconciler := newTestSecretReconciler(t, cfg, secret, deployment) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "key-ref-secret", - Namespace: "default", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue") - } -} - -func TestSecretReconciler_MultipleWorkloads(t *testing.T) { - cfg := config.NewDefault() - - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "shared-secret", - Namespace: "default", - }, - Data: map[string][]byte{"key": []byte("value")}, - } - - deployment1 := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "deployment-1", - Namespace: "default", - Annotations: map[string]string{ - cfg.Annotations.SecretReload: "shared-secret", - }, - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test1"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test1"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - }}, - }, - }, - }, - } - - deployment2 := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "deployment-2", - Namespace: "default", - Annotations: map[string]string{ - cfg.Annotations.SecretReload: "shared-secret", - }, - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test2"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test2"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - }}, - }, - }, - }, - } - - statefulset := &appsv1.StatefulSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: "statefulset-1", - Namespace: "default", - Annotations: map[string]string{ - cfg.Annotations.SecretReload: "shared-secret", - }, - }, - Spec: appsv1.StatefulSetSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "stateful"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "stateful"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - }}, - }, - }, - }, - } - - reconciler := newTestSecretReconciler(t, cfg, secret, deployment1, deployment2, statefulset) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "shared-secret", - Namespace: "default", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue") - } + secret := testSecret("projected-secret", "default") + deployment := testDeploymentWithProjectedVolume("test-deployment", "default", "", "projected-secret") + reconciler := newSecretReconciler(t, cfg, secret, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("projected-secret", "default")) } func TestSecretReconciler_SearchAnnotation(t *testing.T) { cfg := config.NewDefault() - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "default", - Annotations: map[string]string{ - cfg.Annotations.Match: "true", - }, - }, - Data: map[string][]byte{"key": []byte("value")}, - } - - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment", - Namespace: "default", - Annotations: map[string]string{ - cfg.Annotations.Search: "true", - }, - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - }}, - }, - }, - }, - } - - reconciler := newTestSecretReconciler(t, cfg, secret, deployment) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "test-secret", - Namespace: "default", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue") - } + secret := testSecretWithAnnotations("test-secret", "default", map[string]string{ + cfg.Annotations.Match: "true", + }) + deployment := testDeployment("test-deployment", "default", map[string]string{ + cfg.Annotations.Search: "true", + }) + reconciler := newSecretReconciler(t, cfg, secret, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-secret", "default")) } -func TestSecretReconciler_TLSSecret(t *testing.T) { +func TestSecretReconciler_ServiceAccountTokenIgnored(t *testing.T) { cfg := config.NewDefault() + cfg.AutoReloadAll = true - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "tls-secret", - Namespace: "default", - }, - Type: corev1.SecretTypeTLS, - Data: map[string][]byte{ - "tls.crt": []byte("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"), - "tls.key": []byte("-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----"), - }, - } + // Service account tokens should be ignored + secret := testSecret("sa-token", "default") + secret.Type = "kubernetes.io/service-account-token" - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment", - Namespace: "default", - Annotations: map[string]string{ - cfg.Annotations.SecretReload: "tls-secret", - }, - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - }}, - }, - }, - }, - } - - reconciler := newTestSecretReconciler(t, cfg, secret, deployment) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "tls-secret", - Namespace: "default", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue") - } -} - -func TestSecretReconciler_ImagePullSecret(t *testing.T) { - cfg := config.NewDefault() - - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "registry-secret", - Namespace: "default", - }, - Type: corev1.SecretTypeDockerConfigJson, - Data: map[string][]byte{ - ".dockerconfigjson": []byte(`{"auths":{}}`), - }, - } - - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment", - Namespace: "default", - Annotations: map[string]string{ - cfg.Annotations.SecretReload: "registry-secret", - }, - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test"}, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "main", - Image: "nginx", - }}, - ImagePullSecrets: []corev1.LocalObjectReference{{ - Name: "registry-secret", - }}, - }, - }, - }, - } - - reconciler := newTestSecretReconciler(t, cfg, secret, deployment) - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "registry-secret", - Namespace: "default", - }, - } - - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile failed: %v", err) - } - if result.Requeue { - t.Error("Should not requeue") - } + deployment := testDeploymentWithEnvFrom("test-deployment", "default", "", "sa-token") + reconciler := newSecretReconciler(t, cfg, secret, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("sa-token", "default")) } diff --git a/internal/pkg/controller/test_helpers_test.go b/internal/pkg/controller/test_helpers_test.go new file mode 100644 index 00000000..b3315232 --- /dev/null +++ b/internal/pkg/controller/test_helpers_test.go @@ -0,0 +1,418 @@ +package controller_test + +import ( + "context" + "testing" + + "github.com/go-logr/logr/testr" + "github.com/stakater/Reloader/internal/pkg/alerting" + "github.com/stakater/Reloader/internal/pkg/config" + "github.com/stakater/Reloader/internal/pkg/controller" + "github.com/stakater/Reloader/internal/pkg/events" + "github.com/stakater/Reloader/internal/pkg/metrics" + "github.com/stakater/Reloader/internal/pkg/reload" + "github.com/stakater/Reloader/internal/pkg/webhook" + "github.com/stakater/Reloader/internal/pkg/workload" + 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" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +// testScheme is a shared scheme for all controller tests. +func testScheme() *runtime.Scheme { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + _ = appsv1.AddToScheme(scheme) + _ = batchv1.AddToScheme(scheme) + return scheme +} + +// newConfigMapReconciler creates a ConfigMapReconciler for testing. +func newConfigMapReconciler(t *testing.T, cfg *config.Config, objects ...runtime.Object) *controller.ConfigMapReconciler { + t.Helper() + fakeClient := fake.NewClientBuilder(). + WithScheme(testScheme()). + WithRuntimeObjects(objects...). + Build() + + collectors := metrics.NewCollectors() + + return &controller.ConfigMapReconciler{ + Client: fakeClient, + Log: testr.New(t), + Config: cfg, + ReloadService: reload.NewService(cfg), + Registry: workload.NewRegistry(cfg.ArgoRolloutsEnabled), + Collectors: &collectors, + EventRecorder: events.NewRecorder(nil), + WebhookClient: webhook.NewClient("", testr.New(t)), + Alerter: &alerting.NoOpAlerter{}, + } +} + +// newSecretReconciler creates a SecretReconciler for testing. +func newSecretReconciler(t *testing.T, cfg *config.Config, objects ...runtime.Object) *controller.SecretReconciler { + t.Helper() + fakeClient := fake.NewClientBuilder(). + WithScheme(testScheme()). + WithRuntimeObjects(objects...). + Build() + + collectors := metrics.NewCollectors() + + return &controller.SecretReconciler{ + Client: fakeClient, + Log: testr.New(t), + Config: cfg, + ReloadService: reload.NewService(cfg), + Registry: workload.NewRegistry(cfg.ArgoRolloutsEnabled), + Collectors: &collectors, + EventRecorder: events.NewRecorder(nil), + WebhookClient: webhook.NewClient("", testr.New(t)), + Alerter: &alerting.NoOpAlerter{}, + } +} + +// testConfigMap creates a ConfigMap for testing. +func testConfigMap(name, namespace string) *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Data: map[string]string{"key": "value"}, + } +} + +// testConfigMapWithAnnotations creates a ConfigMap with annotations. +func testConfigMapWithAnnotations(name, namespace string, annotations map[string]string) *corev1.ConfigMap { + cm := testConfigMap(name, namespace) + cm.Annotations = annotations + return cm +} + +// testSecret creates a Secret for testing. +func testSecret(name, namespace string) *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Data: map[string][]byte{"key": []byte("value")}, + } +} + +// testSecretWithAnnotations creates a Secret with annotations. +func testSecretWithAnnotations(name, namespace string, annotations map[string]string) *corev1.Secret { + secret := testSecret(name, namespace) + secret.Annotations = annotations + return secret +} + +// testDeployment creates a minimal Deployment for testing. +func testDeployment(name, namespace string, annotations map[string]string) *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Annotations: annotations, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": name}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": name}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + Image: "nginx", + }, + }, + }, + }, + }, + } +} + +// testDeploymentWithEnvFrom creates a Deployment with EnvFrom referencing a ConfigMap or Secret. +func testDeploymentWithEnvFrom(name, namespace string, configMapName, secretName string) *appsv1.Deployment { + d := testDeployment(name, namespace, nil) + if configMapName != "" { + d.Spec.Template.Spec.Containers[0].EnvFrom = append( + d.Spec.Template.Spec.Containers[0].EnvFrom, + corev1.EnvFromSource{ + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: configMapName}, + }, + }, + ) + } + if secretName != "" { + d.Spec.Template.Spec.Containers[0].EnvFrom = append( + d.Spec.Template.Spec.Containers[0].EnvFrom, + corev1.EnvFromSource{ + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, + }, + }, + ) + } + return d +} + +// testDeploymentWithVolume creates a Deployment with a volume from ConfigMap or Secret. +func testDeploymentWithVolume(name, namespace string, configMapName, secretName string) *appsv1.Deployment { + d := testDeployment(name, namespace, nil) + d.Spec.Template.Spec.Containers[0].VolumeMounts = []corev1.VolumeMount{ + { + Name: "config", + MountPath: "/etc/config", + }, + } + + if configMapName != "" { + d.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: configMapName}, + }, + }, + }, + } + } + if secretName != "" { + d.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "config", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: secretName, + }, + }, + }, + } + } + return d +} + +// testDeploymentWithProjectedVolume creates a Deployment with a projected volume. +func testDeploymentWithProjectedVolume(name, namespace string, configMapName, secretName string) *appsv1.Deployment { + d := testDeployment(name, namespace, nil) + d.Spec.Template.Spec.Containers[0].VolumeMounts = []corev1.VolumeMount{ + { + Name: "config", + MountPath: "/etc/config", + }, + } + + var sources []corev1.VolumeProjection + if configMapName != "" { + sources = append( + sources, corev1.VolumeProjection{ + ConfigMap: &corev1.ConfigMapProjection{ + LocalObjectReference: corev1.LocalObjectReference{Name: configMapName}, + }, + }, + ) + } + if secretName != "" { + sources = append( + sources, corev1.VolumeProjection{ + Secret: &corev1.SecretProjection{ + LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, + }, + }, + ) + } + + d.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "config", + VolumeSource: corev1.VolumeSource{ + Projected: &corev1.ProjectedVolumeSource{Sources: sources}, + }, + }, + } + return d +} + +// testDaemonSet creates a minimal DaemonSet for testing. +func testDaemonSet(name, namespace string, annotations map[string]string) *appsv1.DaemonSet { + return &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Annotations: annotations, + }, + Spec: appsv1.DaemonSetSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": name}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": name}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + Image: "nginx", + }, + }, + }, + }, + }, + } +} + +// testStatefulSet creates a minimal StatefulSet for testing. +func testStatefulSet(name, namespace string, annotations map[string]string) *appsv1.StatefulSet { + return &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Annotations: annotations, + }, + Spec: appsv1.StatefulSetSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": name}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": name}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + Image: "nginx", + }, + }, + }, + }, + }, + } +} + +// reconcileRequest creates a ctrl.Request for the given name and namespace. +func reconcileRequest(name, namespace string) ctrl.Request { + return ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: name, + Namespace: namespace, + }, + } +} + +// namespaceRequest creates a ctrl.Request for a namespace (no namespace field needed). +func namespaceRequest(name string) ctrl.Request { + return ctrl.Request{ + NamespacedName: types.NamespacedName{Name: name}, + } +} + +// testNamespace creates a Namespace with optional labels. +func testNamespace(name string, labels map[string]string) *corev1.Namespace { + return &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: labels, + }, + } +} + +// newNamespaceReconciler creates a NamespaceReconciler for testing. +func newNamespaceReconciler(t *testing.T, cfg *config.Config, cache *controller.NamespaceCache, objects ...runtime.Object) *controller.NamespaceReconciler { + t.Helper() + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithRuntimeObjects(objects...). + Build() + + return &controller.NamespaceReconciler{ + Client: fakeClient, + Log: testr.New(t), + Config: cfg, + Cache: cache, + } +} + +// assertReconcileSuccess runs reconcile and asserts no error and no requeue. +func assertReconcileSuccess(t *testing.T, reconciler interface { + Reconcile(context.Context, ctrl.Request) (ctrl.Result, error) +}, req ctrl.Request) { + t.Helper() + result, err := reconciler.Reconcile(context.Background(), req) + if err != nil { + t.Fatalf("Reconcile failed: %v", err) + } + if result.Requeue { + t.Error("Should not requeue") + } +} + +// testJob creates a minimal Job for testing. +func testJob(name, namespace string) *batchv1.Job { + return &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{ + { + Name: "main", + Image: "busybox", + }, + }, + }, + }, + }, + } +} + +// testCronJob creates a minimal CronJob for testing. +func testCronJob(name, namespace string) *batchv1.CronJob { + return &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + UID: "test-uid", + }, + Spec: batchv1.CronJobSpec{ + Schedule: "*/5 * * * *", + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{ + { + Name: "main", + Image: "busybox", + }, + }, + }, + }, + }, + }, + }, + } +} diff --git a/internal/pkg/events/recorder_test.go b/internal/pkg/events/recorder_test.go index 9bf6a917..475173ee 100644 --- a/internal/pkg/events/recorder_test.go +++ b/internal/pkg/events/recorder_test.go @@ -102,7 +102,6 @@ func TestNilRecorder_NoPanic(t *testing.T) { } func TestRecorder_NilInternalRecorder(t *testing.T) { - // Create a Recorder with nil internal recorder (edge case) r := &Recorder{recorder: nil} pod := &corev1.Pod{ @@ -112,26 +111,10 @@ func TestRecorder_NilInternalRecorder(t *testing.T) { }, } - // These should not panic r.ReloadSuccess(pod, "ConfigMap", "my-config") r.ReloadFailed(pod, "Secret", "my-secret", errors.New("test error")) } -func TestEventConstants(t *testing.T) { - if EventTypeNormal != corev1.EventTypeNormal { - t.Errorf("EventTypeNormal = %q, want %q", EventTypeNormal, corev1.EventTypeNormal) - } - if EventTypeWarning != corev1.EventTypeWarning { - t.Errorf("EventTypeWarning = %q, want %q", EventTypeWarning, corev1.EventTypeWarning) - } - if ReasonReloaded != "Reloaded" { - t.Errorf("ReasonReloaded = %q, want %q", ReasonReloaded, "Reloaded") - } - if ReasonReloadFailed != "ReloadFailed" { - t.Errorf("ReasonReloadFailed = %q, want %q", ReasonReloadFailed, "ReloadFailed") - } -} - func TestReloadSuccess_DifferentObjectTypes(t *testing.T) { fakeRecorder := record.NewFakeRecorder(10) r := NewRecorder(fakeRecorder) @@ -155,18 +138,20 @@ func TestReloadSuccess_DifferentObjectTypes(t *testing.T) { } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r.ReloadSuccess(tt.object, "ConfigMap", "my-config") + t.Run( + tt.name, func(t *testing.T) { + r.ReloadSuccess(tt.object, "ConfigMap", "my-config") - select { - case event := <-fakeRecorder.Events: - if event == "" { + select { + case event := <-fakeRecorder.Events: + if event == "" { + t.Error("Expected event to be recorded") + } + default: t.Error("Expected event to be recorded") } - default: - t.Error("Expected event to be recorded") - } - }) + }, + ) } } diff --git a/internal/pkg/metadata/metadata.go b/internal/pkg/metadata/metadata.go index 9bfae8d5..616d987a 100644 --- a/internal/pkg/metadata/metadata.go +++ b/internal/pkg/metadata/metadata.go @@ -40,8 +40,8 @@ var ( type MetaInfo struct { // BuildInfo contains information about the build version, commit, and compilation details. BuildInfo BuildInfo `json:"buildInfo"` - // ReloaderOptions contains all the configuration options used by this Reloader instance. - ReloaderOptions ReloaderOptions `json:"reloaderOptions"` + // Config contains all the configuration options used by this Reloader instance. + Config *config.Config `json:"config"` // DeploymentInfo contains metadata about the Kubernetes deployment of this instance. DeploymentInfo DeploymentInfo `json:"deploymentInfo"` } @@ -66,56 +66,6 @@ type DeploymentInfo struct { Namespace string `json:"namespace"` } -// ReloaderOptions contains the configuration options for Reloader. -// This is a subset of config.Config that's relevant for the metadata ConfigMap. -type ReloaderOptions struct { - // AutoReloadAll enables automatic reloading of all resources. - AutoReloadAll bool `json:"autoReloadAll"` - // ReloadStrategy specifies the strategy used to trigger resource reloads. - ReloadStrategy string `json:"reloadStrategy"` - // IsArgoRollouts indicates whether support for Argo Rollouts is enabled. - IsArgoRollouts bool `json:"isArgoRollouts"` - // ReloadOnCreate indicates whether to trigger reloads when resources are created. - ReloadOnCreate bool `json:"reloadOnCreate"` - // ReloadOnDelete indicates whether to trigger reloads when resources are deleted. - ReloadOnDelete bool `json:"reloadOnDelete"` - // SyncAfterRestart indicates whether to sync add events after Reloader restarts. - SyncAfterRestart bool `json:"syncAfterRestart"` - // EnableHA indicates whether High Availability mode is enabled. - EnableHA bool `json:"enableHA"` - // WebhookURL is the URL to send webhook notifications to. - WebhookURL string `json:"webhookUrl"` - // LogFormat specifies the log format to use. - LogFormat string `json:"logFormat"` - // LogLevel specifies the log level to use. - LogLevel string `json:"logLevel"` - // ResourcesToIgnore is a list of resource types to ignore. - ResourcesToIgnore []string `json:"resourcesToIgnore"` - // WorkloadTypesToIgnore is a list of workload types to ignore. - WorkloadTypesToIgnore []string `json:"workloadTypesToIgnore"` - // NamespacesToIgnore is a list of namespaces to ignore. - NamespacesToIgnore []string `json:"namespacesToIgnore"` - // NamespaceSelectors is a list of namespace label selectors. - NamespaceSelectors []string `json:"namespaceSelectors"` - // ResourceSelectors is a list of resource label selectors. - ResourceSelectors []string `json:"resourceSelectors"` - - // Annotations - ConfigmapUpdateOnChangeAnnotation string `json:"configmapUpdateOnChangeAnnotation"` - SecretUpdateOnChangeAnnotation string `json:"secretUpdateOnChangeAnnotation"` - ReloaderAutoAnnotation string `json:"reloaderAutoAnnotation"` - ConfigmapReloaderAutoAnnotation string `json:"configmapReloaderAutoAnnotation"` - SecretReloaderAutoAnnotation string `json:"secretReloaderAutoAnnotation"` - IgnoreResourceAnnotation string `json:"ignoreResourceAnnotation"` - ConfigmapExcludeReloaderAnnotation string `json:"configmapExcludeReloaderAnnotation"` - SecretExcludeReloaderAnnotation string `json:"secretExcludeReloaderAnnotation"` - AutoSearchAnnotation string `json:"autoSearchAnnotation"` - SearchMatchAnnotation string `json:"searchMatchAnnotation"` - RolloutStrategyAnnotation string `json:"rolloutStrategyAnnotation"` - PauseDeploymentAnnotation string `json:"pauseDeploymentAnnotation"` - PauseDeploymentTimeAnnotation string `json:"pauseDeploymentTimeAnnotation"` -} - // NewBuildInfo creates a new BuildInfo with current build information. func NewBuildInfo() BuildInfo { return BuildInfo{ @@ -126,45 +76,11 @@ func NewBuildInfo() BuildInfo { } } -// NewReloaderOptions creates ReloaderOptions from a Config. -func NewReloaderOptions(cfg *config.Config) ReloaderOptions { - return ReloaderOptions{ - AutoReloadAll: cfg.AutoReloadAll, - ReloadStrategy: string(cfg.ReloadStrategy), - IsArgoRollouts: cfg.ArgoRolloutsEnabled, - ReloadOnCreate: cfg.ReloadOnCreate, - ReloadOnDelete: cfg.ReloadOnDelete, - SyncAfterRestart: cfg.SyncAfterRestart, - EnableHA: cfg.EnableHA, - WebhookURL: cfg.WebhookURL, - LogFormat: cfg.LogFormat, - LogLevel: cfg.LogLevel, - ResourcesToIgnore: cfg.IgnoredResources, - WorkloadTypesToIgnore: cfg.IgnoredWorkloads, - NamespacesToIgnore: cfg.IgnoredNamespaces, - NamespaceSelectors: cfg.NamespaceSelectorStrings, - ResourceSelectors: cfg.ResourceSelectorStrings, - ConfigmapUpdateOnChangeAnnotation: cfg.Annotations.ConfigmapReload, - SecretUpdateOnChangeAnnotation: cfg.Annotations.SecretReload, - ReloaderAutoAnnotation: cfg.Annotations.Auto, - ConfigmapReloaderAutoAnnotation: cfg.Annotations.ConfigmapAuto, - SecretReloaderAutoAnnotation: cfg.Annotations.SecretAuto, - IgnoreResourceAnnotation: cfg.Annotations.Ignore, - ConfigmapExcludeReloaderAnnotation: cfg.Annotations.ConfigmapExclude, - SecretExcludeReloaderAnnotation: cfg.Annotations.SecretExclude, - AutoSearchAnnotation: cfg.Annotations.Search, - SearchMatchAnnotation: cfg.Annotations.Match, - RolloutStrategyAnnotation: cfg.Annotations.RolloutStrategy, - PauseDeploymentAnnotation: cfg.Annotations.PausePeriod, - PauseDeploymentTimeAnnotation: cfg.Annotations.PausedAt, - } -} - // NewMetaInfo creates a new MetaInfo from configuration. func NewMetaInfo(cfg *config.Config) *MetaInfo { return &MetaInfo{ - BuildInfo: NewBuildInfo(), - ReloaderOptions: NewReloaderOptions(cfg), + BuildInfo: NewBuildInfo(), + Config: cfg, DeploymentInfo: DeploymentInfo{ Name: os.Getenv(EnvReloaderDeploymentName), Namespace: os.Getenv(EnvReloaderNamespace), @@ -183,9 +99,9 @@ func (m *MetaInfo) ToConfigMap() *corev1.ConfigMap { }, }, Data: map[string]string{ - "buildInfo": toJSON(m.BuildInfo), - "reloaderOptions": toJSON(m.ReloaderOptions), - "deploymentInfo": toJSON(m.DeploymentInfo), + "buildInfo": toJSON(m.BuildInfo), + "config": toJSON(m.Config), + "deploymentInfo": toJSON(m.DeploymentInfo), }, } } diff --git a/internal/pkg/metadata/metadata_test.go b/internal/pkg/metadata/metadata_test.go index 2ef4f575..fd87d1d6 100644 --- a/internal/pkg/metadata/metadata_test.go +++ b/internal/pkg/metadata/metadata_test.go @@ -19,7 +19,6 @@ func testLogger() logr.Logger { } func TestNewBuildInfo(t *testing.T) { - // Set build variables for testing oldVersion := Version oldCommit := Commit oldBuildDate := BuildDate @@ -49,7 +48,10 @@ func TestNewBuildInfo(t *testing.T) { } } -func TestNewReloaderOptions(t *testing.T) { +func TestNewMetaInfo(t *testing.T) { + t.Setenv(EnvReloaderNamespace, "test-ns") + t.Setenv(EnvReloaderDeploymentName, "test-deploy") + cfg := config.NewDefault() cfg.AutoReloadAll = true cfg.ReloadStrategy = config.ReloadStrategyAnnotations @@ -64,53 +66,39 @@ func TestNewReloaderOptions(t *testing.T) { cfg.IgnoredWorkloads = []string{"jobs"} cfg.IgnoredNamespaces = []string{"kube-system"} - opts := NewReloaderOptions(cfg) + metaInfo := NewMetaInfo(cfg) - if !opts.AutoReloadAll { + if !metaInfo.Config.AutoReloadAll { t.Error("AutoReloadAll should be true") } - if opts.ReloadStrategy != "annotations" { - t.Errorf("ReloadStrategy = %s, want annotations", opts.ReloadStrategy) + if metaInfo.Config.ReloadStrategy != config.ReloadStrategyAnnotations { + t.Errorf("ReloadStrategy = %s, want annotations", metaInfo.Config.ReloadStrategy) } - if !opts.IsArgoRollouts { - t.Error("IsArgoRollouts should be true") + if !metaInfo.Config.ArgoRolloutsEnabled { + t.Error("ArgoRolloutsEnabled should be true") } - if !opts.ReloadOnCreate { + if !metaInfo.Config.ReloadOnCreate { t.Error("ReloadOnCreate should be true") } - if !opts.ReloadOnDelete { + if !metaInfo.Config.ReloadOnDelete { t.Error("ReloadOnDelete should be true") } - if !opts.EnableHA { + if !metaInfo.Config.EnableHA { t.Error("EnableHA should be true") } - if opts.WebhookURL != "https://example.com/webhook" { - t.Errorf("WebhookURL = %s, want https://example.com/webhook", opts.WebhookURL) - } - if opts.LogFormat != "json" { - t.Errorf("LogFormat = %s, want json", opts.LogFormat) - } - if opts.LogLevel != "debug" { - t.Errorf("LogLevel = %s, want debug", opts.LogLevel) - } - if len(opts.ResourcesToIgnore) != 1 || opts.ResourcesToIgnore[0] != "configmaps" { - t.Errorf("ResourcesToIgnore = %v, want [configmaps]", opts.ResourcesToIgnore) - } - if len(opts.WorkloadTypesToIgnore) != 1 || opts.WorkloadTypesToIgnore[0] != "jobs" { - t.Errorf("WorkloadTypesToIgnore = %v, want [jobs]", opts.WorkloadTypesToIgnore) - } - if len(opts.NamespacesToIgnore) != 1 || opts.NamespacesToIgnore[0] != "kube-system" { - t.Errorf("NamespacesToIgnore = %v, want [kube-system]", opts.NamespacesToIgnore) + if metaInfo.Config.WebhookURL != "https://example.com/webhook" { + t.Errorf("WebhookURL = %s, want https://example.com/webhook", metaInfo.Config.WebhookURL) } - // Check annotations - if opts.ReloaderAutoAnnotation != "reloader.stakater.com/auto" { - t.Errorf("ReloaderAutoAnnotation = %s, want reloader.stakater.com/auto", opts.ReloaderAutoAnnotation) + if metaInfo.DeploymentInfo.Namespace != "test-ns" { + t.Errorf("DeploymentInfo.Namespace = %s, want test-ns", metaInfo.DeploymentInfo.Namespace) + } + if metaInfo.DeploymentInfo.Name != "test-deploy" { + t.Errorf("DeploymentInfo.Name = %s, want test-deploy", metaInfo.DeploymentInfo.Name) } } func TestMetaInfo_ToConfigMap(t *testing.T) { - // Set environment variables t.Setenv(EnvReloaderNamespace, "reloader-ns") t.Setenv(EnvReloaderDeploymentName, "reloader-deploy") @@ -128,12 +116,11 @@ func TestMetaInfo_ToConfigMap(t *testing.T) { t.Errorf("Label = %s, want %s", cm.Labels[ConfigMapLabelKey], ConfigMapLabelValue) } - // Check data fields exist if _, ok := cm.Data["buildInfo"]; !ok { t.Error("buildInfo data key missing") } - if _, ok := cm.Data["reloaderOptions"]; !ok { - t.Error("reloaderOptions data key missing") + if _, ok := cm.Data["config"]; !ok { + t.Error("config data key missing") } if _, ok := cm.Data["deploymentInfo"]; !ok { t.Error("deploymentInfo data key missing") @@ -145,6 +132,11 @@ func TestMetaInfo_ToConfigMap(t *testing.T) { t.Errorf("buildInfo is not valid JSON: %v", err) } + var parsedConfig config.Config + if err := json.Unmarshal([]byte(cm.Data["config"]), &parsedConfig); err != nil { + t.Errorf("config is not valid JSON: %v", err) + } + // Verify deploymentInfo contains expected values var deployInfo DeploymentInfo if err := json.Unmarshal([]byte(cm.Data["deploymentInfo"]), &deployInfo); err != nil { @@ -159,7 +151,6 @@ func TestMetaInfo_ToConfigMap(t *testing.T) { } func TestPublisher_Publish_NoNamespace(t *testing.T) { - // Ensure RELOADER_NAMESPACE is not set (empty value) t.Setenv(EnvReloaderNamespace, "") scheme := runtime.NewScheme() @@ -176,7 +167,6 @@ func TestPublisher_Publish_NoNamespace(t *testing.T) { } func TestPublisher_Publish_CreateNew(t *testing.T) { - // Set environment variables t.Setenv(EnvReloaderNamespace, "test-ns") t.Setenv(EnvReloaderDeploymentName, "test-deploy") @@ -193,7 +183,6 @@ func TestPublisher_Publish_CreateNew(t *testing.T) { t.Errorf("Publish() error = %v", err) } - // Verify ConfigMap was created cm := &corev1.ConfigMap{} err = fakeClient.Get(ctx, client.ObjectKey{Name: ConfigMapName, Namespace: "test-ns"}, cm) if err != nil { @@ -205,14 +194,12 @@ func TestPublisher_Publish_CreateNew(t *testing.T) { } func TestPublisher_Publish_UpdateExisting(t *testing.T) { - // Set environment variables t.Setenv(EnvReloaderNamespace, "test-ns") t.Setenv(EnvReloaderDeploymentName, "test-deploy") scheme := runtime.NewScheme() _ = corev1.AddToScheme(scheme) - // Create existing ConfigMap with old data existingCM := &corev1.ConfigMap{} existingCM.Name = ConfigMapName existingCM.Namespace = "test-ns" @@ -234,32 +221,28 @@ func TestPublisher_Publish_UpdateExisting(t *testing.T) { t.Errorf("Publish() error = %v", err) } - // Verify ConfigMap was updated cm := &corev1.ConfigMap{} err = fakeClient.Get(ctx, client.ObjectKey{Name: ConfigMapName, Namespace: "test-ns"}, cm) if err != nil { t.Errorf("Failed to get updated ConfigMap: %v", err) } - // Check that all data keys are present if _, ok := cm.Data["buildInfo"]; !ok { t.Error("buildInfo data key missing after update") } - if _, ok := cm.Data["reloaderOptions"]; !ok { - t.Error("reloaderOptions data key missing after update") + if _, ok := cm.Data["config"]; !ok { + t.Error("config data key missing after update") } if _, ok := cm.Data["deploymentInfo"]; !ok { t.Error("deploymentInfo data key missing after update") } - // Verify labels were added if cm.Labels[ConfigMapLabelKey] != ConfigMapLabelValue { t.Errorf("Label not updated: %s", cm.Labels[ConfigMapLabelKey]) } } func TestPublishMetaInfoConfigMap(t *testing.T) { - // Set environment variables t.Setenv(EnvReloaderNamespace, "test-ns") scheme := runtime.NewScheme() @@ -274,7 +257,6 @@ func TestPublishMetaInfoConfigMap(t *testing.T) { t.Errorf("PublishMetaInfoConfigMap() error = %v", err) } - // Verify ConfigMap was created cm := &corev1.ConfigMap{} err = fakeClient.Get(ctx, client.ObjectKey{Name: ConfigMapName, Namespace: "test-ns"}, cm) if err != nil { @@ -306,17 +288,19 @@ func TestParseUTCTime(t *testing.T) { } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := parseUTCTime(tt.input) - if tt.wantErr { - if !result.IsZero() { - t.Errorf("parseUTCTime(%s) should return zero time", tt.input) + t.Run( + tt.name, func(t *testing.T) { + result := parseUTCTime(tt.input) + if tt.wantErr { + if !result.IsZero() { + t.Errorf("parseUTCTime(%s) should return zero time", tt.input) + } + } else { + if result.IsZero() { + t.Errorf("parseUTCTime(%s) should not return zero time", tt.input) + } } - } else { - if result.IsZero() { - t.Errorf("parseUTCTime(%s) should not return zero time", tt.input) - } - } - }) + }, + ) } } diff --git a/internal/pkg/metrics/prometheus_test.go b/internal/pkg/metrics/prometheus_test.go index b1b85101..5715c243 100644 --- a/internal/pkg/metrics/prometheus_test.go +++ b/internal/pkg/metrics/prometheus_test.go @@ -21,7 +21,6 @@ func TestNewCollectors_CreatesCounters(t *testing.T) { func TestNewCollectors_InitializesWithZero(t *testing.T) { collectors := NewCollectors() - // Check that success=true counter is initialized to 0 metric := &dto.Metric{} err := collectors.Reloaded.With(prometheus.Labels{"success": "true"}).Write(metric) if err != nil { @@ -31,7 +30,6 @@ func TestNewCollectors_InitializesWithZero(t *testing.T) { t.Errorf("Initial success=true counter = %v, want 0", metric.Counter.GetValue()) } - // Check that success=false counter is initialized to 0 err = collectors.Reloaded.With(prometheus.Labels{"success": "false"}).Write(metric) if err != nil { t.Fatalf("Failed to get metric: %v", err) @@ -95,17 +93,18 @@ func TestRecordReload_MultipleIncrements(t *testing.T) { } func TestRecordReload_WithNamespaceTracking(t *testing.T) { - // Enable namespace tracking t.Setenv("METRICS_COUNT_BY_NAMESPACE", "enabled") collectors := NewCollectors() collectors.RecordReload(true, "kube-system") metric := &dto.Metric{} - err := collectors.ReloadedByNamespace.With(prometheus.Labels{ - "success": "true", - "namespace": "kube-system", - }).Write(metric) + err := collectors.ReloadedByNamespace.With( + prometheus.Labels{ + "success": "true", + "namespace": "kube-system", + }, + ).Write(metric) if err != nil { t.Fatalf("Failed to get metric: %v", err) } @@ -115,14 +114,11 @@ func TestRecordReload_WithNamespaceTracking(t *testing.T) { } func TestRecordReload_WithoutNamespaceTracking(t *testing.T) { - // Ensure namespace tracking is disabled (t.Setenv to empty resets it) t.Setenv("METRICS_COUNT_BY_NAMESPACE", "") collectors := NewCollectors() collectors.RecordReload(true, "kube-system") - // The ReloadedByNamespace counter should not be incremented - // We can verify by checking countByNamespace is false if collectors.countByNamespace { t.Error("countByNamespace should be false when env var is not set") } @@ -131,7 +127,6 @@ func TestRecordReload_WithoutNamespaceTracking(t *testing.T) { func TestNilCollectors_NoPanic(t *testing.T) { var c *Collectors = nil - // This should not panic c.RecordReload(true, "default") c.RecordReload(false, "default") } @@ -146,11 +141,12 @@ func TestRecordReload_DifferentNamespaces(t *testing.T) { metric := &dto.Metric{} - // Check namespace-a has 2 reloads - err := collectors.ReloadedByNamespace.With(prometheus.Labels{ - "success": "true", - "namespace": "namespace-a", - }).Write(metric) + err := collectors.ReloadedByNamespace.With( + prometheus.Labels{ + "success": "true", + "namespace": "namespace-a", + }, + ).Write(metric) if err != nil { t.Fatalf("Failed to get metric: %v", err) } @@ -158,11 +154,12 @@ func TestRecordReload_DifferentNamespaces(t *testing.T) { t.Errorf("namespace-a counter = %v, want 2", metric.Counter.GetValue()) } - // Check namespace-b has 1 reload - err = collectors.ReloadedByNamespace.With(prometheus.Labels{ - "success": "true", - "namespace": "namespace-b", - }).Write(metric) + err = collectors.ReloadedByNamespace.With( + prometheus.Labels{ + "success": "true", + "namespace": "namespace-b", + }, + ).Write(metric) if err != nil { t.Fatalf("Failed to get metric: %v", err) } @@ -174,7 +171,6 @@ func TestRecordReload_DifferentNamespaces(t *testing.T) { func TestCollectors_MetricNames(t *testing.T) { collectors := NewCollectors() - // Verify the Reloaded metric has correct description ch := make(chan *prometheus.Desc, 10) collectors.Reloaded.Describe(ch) close(ch) diff --git a/internal/pkg/reload/decision_test.go b/internal/pkg/reload/decision_test.go index eb158d1a..fdc011a6 100644 --- a/internal/pkg/reload/decision_test.go +++ b/internal/pkg/reload/decision_test.go @@ -9,28 +9,33 @@ import ( ) 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"}, - }) + 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 string + decisions []ReloadDecision + wantCount int + wantNames []string }{ { - name: "empty list", - decisions: []ReloadDecision{}, - wantCount: 0, - wantNames: nil, + name: "empty list", + decisions: []ReloadDecision{}, + wantCount: 0, + wantNames: nil, }, { name: "all should reload", @@ -63,29 +68,35 @@ func TestFilterDecisions(t *testing.T) { } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := FilterDecisions(tt.decisions) + 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 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]) + 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"}, - }) + wl := workload.NewDeploymentWorkload( + &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + }, + ) decision := ReloadDecision{ Workload: wl, diff --git a/internal/pkg/reload/hasher_test.go b/internal/pkg/reload/hasher_test.go index 92edbf34..ff5693ff 100644 --- a/internal/pkg/reload/hasher_test.go +++ b/internal/pkg/reload/hasher_test.go @@ -20,7 +20,6 @@ func TestHasher_HashConfigMap(t *testing.T) { Data: nil, BinaryData: nil, }, - // Empty configmap gets a valid hash (hash of empty data) wantHash: hasher.HashConfigMap(&corev1.ConfigMap{}), }, { @@ -31,13 +30,14 @@ func TestHasher_HashConfigMap(t *testing.T) { "key2": "value2", }, }, - // Hash should be deterministic - wantHash: hasher.HashConfigMap(&corev1.ConfigMap{ - Data: map[string]string{ - "key1": "value1", - "key2": "value2", + wantHash: hasher.HashConfigMap( + &corev1.ConfigMap{ + Data: map[string]string{ + "key1": "value1", + "key2": "value2", + }, }, - }), + ), }, { name: "configmap with binary data", @@ -46,21 +46,25 @@ func TestHasher_HashConfigMap(t *testing.T) { "binary1": []byte("binaryvalue1"), }, }, - wantHash: hasher.HashConfigMap(&corev1.ConfigMap{ - BinaryData: map[string][]byte{ - "binary1": []byte("binaryvalue1"), + wantHash: hasher.HashConfigMap( + &corev1.ConfigMap{ + BinaryData: map[string][]byte{ + "binary1": []byte("binaryvalue1"), + }, }, - }), + ), }, } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := hasher.HashConfigMap(tt.cm) - if got != tt.wantHash { - t.Errorf("HashConfigMap() = %v, want %v", got, tt.wantHash) - } - }) + t.Run( + tt.name, func(t *testing.T) { + got := hasher.HashConfigMap(tt.cm) + if got != tt.wantHash { + t.Errorf("HashConfigMap() = %v, want %v", got, tt.wantHash) + } + }, + ) } } @@ -75,7 +79,6 @@ func TestHasher_HashConfigMap_Deterministic(t *testing.T) { }, } - // Hash should be the same regardless of iteration order hash1 := hasher.HashConfigMap(cm) hash2 := hasher.HashConfigMap(cm) hash3 := hasher.HashConfigMap(cm) @@ -121,7 +124,6 @@ func TestHasher_HashSecret(t *testing.T) { secret: &corev1.Secret{ Data: nil, }, - // Empty secret gets a valid hash (hash of empty data) wantHash: hasher.HashSecret(&corev1.Secret{}), }, { @@ -132,22 +134,26 @@ func TestHasher_HashSecret(t *testing.T) { "key2": []byte("value2"), }, }, - wantHash: hasher.HashSecret(&corev1.Secret{ - Data: map[string][]byte{ - "key1": []byte("value1"), - "key2": []byte("value2"), + wantHash: hasher.HashSecret( + &corev1.Secret{ + Data: map[string][]byte{ + "key1": []byte("value1"), + "key2": []byte("value2"), + }, }, - }), + ), }, } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := hasher.HashSecret(tt.secret) - if got != tt.wantHash { - t.Errorf("HashSecret() = %v, want %v", got, tt.wantHash) - } - }) + t.Run( + tt.name, func(t *testing.T) { + got := hasher.HashSecret(tt.secret) + if got != tt.wantHash { + t.Errorf("HashSecret() = %v, want %v", got, tt.wantHash) + } + }, + ) } } @@ -162,7 +168,6 @@ func TestHasher_HashSecret_Deterministic(t *testing.T) { }, } - // Hash should be the same regardless of iteration order hash1 := hasher.HashSecret(secret) hash2 := hasher.HashSecret(secret) hash3 := hasher.HashSecret(secret) @@ -198,20 +203,17 @@ func TestHasher_HashSecret_DifferentValues(t *testing.T) { func TestHasher_EmptyHash(t *testing.T) { hasher := NewHasher() - // EmptyHash returns empty string to signal deletion emptyHash := hasher.EmptyHash() if emptyHash != "" { t.Errorf("EmptyHash should be empty string, got %s", emptyHash) } - // Empty ConfigMap should have a valid hash (not empty) cm := &corev1.ConfigMap{} cmHash := hasher.HashConfigMap(cm) if cmHash == "" { t.Error("Empty ConfigMap should have a non-empty hash") } - // Empty Secret should have a valid hash (not empty) secret := &corev1.Secret{} secretHash := hasher.HashSecret(secret) if secretHash == "" { @@ -222,13 +224,11 @@ func TestHasher_EmptyHash(t *testing.T) { func TestHasher_NilInput(t *testing.T) { hasher := NewHasher() - // Test nil ConfigMap - returns hash of empty content (not EmptyHash) cmHash := hasher.HashConfigMap(nil) if cmHash == "" { t.Error("nil ConfigMap should return a valid hash") } - // Test nil Secret - returns hash of empty content (not EmptyHash) secretHash := hasher.HashSecret(nil) if secretHash == "" { t.Error("nil Secret should return a valid hash") diff --git a/internal/pkg/reload/matcher_test.go b/internal/pkg/reload/matcher_test.go index 8595d0ab..1c58fd30 100644 --- a/internal/pkg/reload/matcher_test.go +++ b/internal/pkg/reload/matcher_test.go @@ -17,7 +17,6 @@ func TestMatcher_ShouldReload(t *testing.T) { wantAutoReload bool description string }{ - // Ignore annotation tests { name: "ignore annotation on resource skips reload", input: MatchInput{ @@ -46,8 +45,6 @@ func TestMatcher_ShouldReload(t *testing.T) { wantAutoReload: true, description: "Resources with ignore=false should allow reload", }, - - // Exclude annotation tests { name: "exclude annotation skips reload", input: MatchInput{ @@ -82,8 +79,6 @@ func TestMatcher_ShouldReload(t *testing.T) { wantAutoReload: false, description: "ConfigMaps in comma-separated exclude list should not trigger reload", }, - - // BUG FIX: Explicit annotation checked BEFORE auto { name: "explicit reload annotation with auto enabled - should reload", input: MatchInput{ @@ -133,8 +128,6 @@ func TestMatcher_ShouldReload(t *testing.T) { wantAutoReload: false, description: "ConfigMaps not in reload list should not trigger reload", }, - - // Auto annotation tests { name: "auto annotation on workload triggers reload", input: MatchInput{ @@ -205,8 +198,6 @@ func TestMatcher_ShouldReload(t *testing.T) { wantAutoReload: false, description: "ConfigMap-specific auto annotation should not match secrets", }, - - // Search/Match annotation tests { name: "search annotation with matching resource", input: MatchInput{ @@ -235,8 +226,6 @@ func TestMatcher_ShouldReload(t *testing.T) { wantAutoReload: false, description: "Search annotation without matching resource should not trigger reload", }, - - // No annotations - should not reload { name: "no annotations does not trigger reload", input: MatchInput{ @@ -251,8 +240,6 @@ func TestMatcher_ShouldReload(t *testing.T) { wantAutoReload: false, description: "Without any annotations, should not trigger reload", }, - - // Secret tests { name: "secret reload annotation", input: MatchInput{ @@ -289,19 +276,21 @@ func TestMatcher_ShouldReload(t *testing.T) { } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := matcher.ShouldReload(tt.input) + t.Run( + tt.name, func(t *testing.T) { + result := matcher.ShouldReload(tt.input) - if result.ShouldReload != tt.wantReload { - t.Errorf("ShouldReload = %v, want %v (%s)", result.ShouldReload, tt.wantReload, tt.description) - } + if result.ShouldReload != tt.wantReload { + t.Errorf("ShouldReload = %v, want %v (%s)", result.ShouldReload, tt.wantReload, tt.description) + } - if result.AutoReload != tt.wantAutoReload { - t.Errorf("AutoReload = %v, want %v (%s)", result.AutoReload, tt.wantAutoReload, tt.description) - } + if result.AutoReload != tt.wantAutoReload { + t.Errorf("AutoReload = %v, want %v (%s)", result.AutoReload, tt.wantAutoReload, tt.description) + } - t.Logf("✓ %s", tt.description) - }) + t.Logf("✓ %s", tt.description) + }, + ) } } @@ -364,39 +353,31 @@ func TestMatcher_ShouldReload_AutoReloadAll(t *testing.T) { } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := matcher.ShouldReload(tt.input) + t.Run( + tt.name, func(t *testing.T) { + result := matcher.ShouldReload(tt.input) - if result.ShouldReload != tt.wantReload { - t.Errorf("ShouldReload = %v, want %v (%s)", result.ShouldReload, tt.wantReload, tt.description) - } + if result.ShouldReload != tt.wantReload { + t.Errorf("ShouldReload = %v, want %v (%s)", result.ShouldReload, tt.wantReload, tt.description) + } - if result.AutoReload != tt.wantAutoReload { - t.Errorf("AutoReload = %v, want %v (%s)", result.AutoReload, tt.wantAutoReload, tt.description) - } + if result.AutoReload != tt.wantAutoReload { + t.Errorf("AutoReload = %v, want %v (%s)", result.AutoReload, tt.wantAutoReload, tt.description) + } - t.Logf("✓ %s", tt.description) - }) + t.Logf("✓ %s", tt.description) + }, + ) } } -// TestMatcher_BugFix_AutoDoesNotIgnoreExplicit tests the fix for the bug where +// TestMatcher_AutoDoesNotIgnoreExplicit tests the fix for the bug where // having reloader.stakater.com/auto: "true" would cause explicit reload annotations // to be ignored due to an early return. -func TestMatcher_BugFix_AutoDoesNotIgnoreExplicit(t *testing.T) { +func TestMatcher_AutoDoesNotIgnoreExplicit(t *testing.T) { cfg := config.NewDefault() matcher := NewMatcher(cfg) - // This is the exact scenario from the bug report: - // Workload has: - // reloader.stakater.com/auto: "true" (watches all referenced CMs) - // configmap.reloader.stakater.com/reload: "external-config" (ALSO watches this one) - // Container references: app-config - // - // When "external-config" changes: - // - Expected: Reload (explicitly listed) - // - Bug behavior: No reload (auto annotation causes early return) - input := MatchInput{ ResourceName: "external-config", // Not referenced by workload ResourceNamespace: "default", @@ -416,12 +397,11 @@ func TestMatcher_BugFix_AutoDoesNotIgnoreExplicit(t *testing.T) { t.Errorf("Expected ShouldReload=true for explicitly listed ConfigMap, got false") } - // Should be marked as non-auto since it matched the explicit list if result.AutoReload { t.Errorf("Expected AutoReload=false for explicit match, got true") } - t.Log("✓ Bug fixed: Explicit reload annotation works even when auto is enabled") + t.Log("✓ Explicit reload annotation works even when auto is enabled") } // TestMatcher_PrecedenceOrder verifies the correct order of precedence: @@ -435,54 +415,60 @@ func TestMatcher_PrecedenceOrder(t *testing.T) { cfg := config.NewDefault() matcher := NewMatcher(cfg) - t.Run("explicit takes precedence over auto", func(t *testing.T) { - input := MatchInput{ - ResourceName: "my-config", - ResourceNamespace: "default", - ResourceType: ResourceTypeConfigMap, - WorkloadAnnotations: map[string]string{ - "reloader.stakater.com/auto": "true", - "configmap.reloader.stakater.com/reload": "my-config", - }, - } - result := matcher.ShouldReload(input) - if result.AutoReload { - t.Error("Expected explicit match (AutoReload=false), got auto match") - } - if !result.ShouldReload { - t.Error("Expected ShouldReload=true") - } - }) + t.Run( + "explicit takes precedence over auto", func(t *testing.T) { + input := MatchInput{ + ResourceName: "my-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + WorkloadAnnotations: map[string]string{ + "reloader.stakater.com/auto": "true", + "configmap.reloader.stakater.com/reload": "my-config", + }, + } + result := matcher.ShouldReload(input) + if result.AutoReload { + t.Error("Expected explicit match (AutoReload=false), got auto match") + } + if !result.ShouldReload { + t.Error("Expected ShouldReload=true") + } + }, + ) - t.Run("ignore takes precedence over explicit", func(t *testing.T) { - input := MatchInput{ - ResourceName: "my-config", - ResourceNamespace: "default", - ResourceType: ResourceTypeConfigMap, - ResourceAnnotations: map[string]string{"reloader.stakater.com/ignore": "true"}, - WorkloadAnnotations: map[string]string{ - "configmap.reloader.stakater.com/reload": "my-config", - }, - } - result := matcher.ShouldReload(input) - if result.ShouldReload { - t.Error("Expected ignore to take precedence, but got ShouldReload=true") - } - }) + t.Run( + "ignore takes precedence over explicit", func(t *testing.T) { + input := MatchInput{ + ResourceName: "my-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: map[string]string{"reloader.stakater.com/ignore": "true"}, + WorkloadAnnotations: map[string]string{ + "configmap.reloader.stakater.com/reload": "my-config", + }, + } + result := matcher.ShouldReload(input) + if result.ShouldReload { + t.Error("Expected ignore to take precedence, but got ShouldReload=true") + } + }, + ) - t.Run("exclude takes precedence over explicit", func(t *testing.T) { - input := MatchInput{ - ResourceName: "my-config", - ResourceNamespace: "default", - ResourceType: ResourceTypeConfigMap, - WorkloadAnnotations: map[string]string{ - "configmap.reloader.stakater.com/reload": "my-config", - "configmaps.exclude.reloader.stakater.com/reload": "my-config", - }, - } - result := matcher.ShouldReload(input) - if result.ShouldReload { - t.Error("Expected exclude to take precedence, but got ShouldReload=true") - } - }) + t.Run( + "exclude takes precedence over explicit", func(t *testing.T) { + input := MatchInput{ + ResourceName: "my-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + WorkloadAnnotations: map[string]string{ + "configmap.reloader.stakater.com/reload": "my-config", + "configmaps.exclude.reloader.stakater.com/reload": "my-config", + }, + } + result := matcher.ShouldReload(input) + if result.ShouldReload { + t.Error("Expected exclude to take precedence, but got ShouldReload=true") + } + }, + ) } diff --git a/internal/pkg/reload/pause.go b/internal/pkg/reload/pause.go index 1d00d313..e6e33366 100644 --- a/internal/pkg/reload/pause.go +++ b/internal/pkg/reload/pause.go @@ -58,10 +58,8 @@ func (h *PauseHandler) ApplyPause(wl workload.WorkloadAccessor) error { deploy := deployWl.GetDeployment() - // Set paused flag deploy.Spec.Paused = true - // Set paused-at annotation if deploy.Annotations == nil { deploy.Annotations = make(map[string]string) } @@ -109,7 +107,6 @@ func (h *PauseHandler) CheckPauseExpired(deploy *appsv1.Deployment) (expired boo func (h *PauseHandler) ClearPause(deploy *appsv1.Deployment) { deploy.Spec.Paused = false delete(deploy.Annotations, h.cfg.Annotations.PausedAt) - // Keep pause-period annotation (user's config) } // IsPausedByReloader checks if a deployment was paused by Reloader. diff --git a/internal/pkg/reload/predicate.go b/internal/pkg/reload/predicate.go index 3582dd54..f504694a 100644 --- a/internal/pkg/reload/predicate.go +++ b/internal/pkg/reload/predicate.go @@ -33,26 +33,30 @@ func resourcePredicates(cfg *config.Config, hashFn func(old, new client.Object) // ConfigMapPredicates returns predicates for filtering ConfigMap events. func ConfigMapPredicates(cfg *config.Config, hasher *Hasher) predicate.Predicate { - return resourcePredicates(cfg, func(old, new client.Object) (string, string, bool) { - oldCM, okOld := old.(*corev1.ConfigMap) - newCM, okNew := new.(*corev1.ConfigMap) - if !okOld || !okNew { - return "", "", false - } - return hasher.HashConfigMap(oldCM), hasher.HashConfigMap(newCM), true - }) + return resourcePredicates( + cfg, func(old, new client.Object) (string, string, bool) { + oldCM, okOld := old.(*corev1.ConfigMap) + newCM, okNew := new.(*corev1.ConfigMap) + if !okOld || !okNew { + return "", "", false + } + return hasher.HashConfigMap(oldCM), hasher.HashConfigMap(newCM), true + }, + ) } // SecretPredicates returns predicates for filtering Secret events. func SecretPredicates(cfg *config.Config, hasher *Hasher) predicate.Predicate { - return resourcePredicates(cfg, func(old, new client.Object) (string, string, bool) { - oldSecret, okOld := old.(*corev1.Secret) - newSecret, okNew := new.(*corev1.Secret) - if !okOld || !okNew { - return "", "", false - } - return hasher.HashSecret(oldSecret), hasher.HashSecret(newSecret), true - }) + return resourcePredicates( + cfg, func(old, new client.Object) (string, string, bool) { + oldSecret, okOld := old.(*corev1.Secret) + newSecret, okNew := new.(*corev1.Secret) + if !okOld || !okNew { + return "", "", false + } + return hasher.HashSecret(oldSecret), hasher.HashSecret(newSecret), true + }, + ) } // NamespaceChecker defines the interface for checking if a namespace is allowed. @@ -68,47 +72,49 @@ func NamespaceFilterPredicate(cfg *config.Config) predicate.Predicate { // 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() + return predicate.NewPredicateFuncs( + func(obj client.Object) bool { + namespace := obj.GetNamespace() - // Check if namespace should be ignored - if cfg.IsNamespaceIgnored(namespace) { - return false - } + if cfg.IsNamespaceIgnored(namespace) { + return false + } - // Check namespace selector cache if provided - if nsCache != nil && !nsCache.Contains(namespace) { - return false - } + if nsCache != nil && !nsCache.Contains(namespace) { + return false + } - return true - }) + return true + }, + ) } // LabelSelectorPredicate returns a predicate that filters resources by labels. func LabelSelectorPredicate(cfg *config.Config) predicate.Predicate { if len(cfg.ResourceSelectors) == 0 { - // No selectors configured, allow all - return predicate.NewPredicateFuncs(func(obj client.Object) bool { - return true - }) + return predicate.NewPredicateFuncs( + func(obj client.Object) bool { + return true + }, + ) } - return predicate.NewPredicateFuncs(func(obj client.Object) bool { - labels := obj.GetLabels() - if labels == nil { - labels = make(map[string]string) - } - - // Check if any selector matches - for _, selector := range cfg.ResourceSelectors { - if selector.Matches(LabelsSet(labels)) { - return true + return predicate.NewPredicateFuncs( + func(obj client.Object) bool { + labels := obj.GetLabels() + if labels == nil { + labels = make(map[string]string) } - } - return false - }) + for _, selector := range cfg.ResourceSelectors { + if selector.Matches(LabelsSet(labels)) { + return true + } + } + + return false + }, + ) } // LabelsSet implements the k8s.io/apimachinery/pkg/labels.Labels interface @@ -128,15 +134,16 @@ func (ls LabelsSet) Get(key string) string { // IgnoreAnnotationPredicate returns a predicate that filters out resources with the ignore annotation. func IgnoreAnnotationPredicate(cfg *config.Config) predicate.Predicate { - return predicate.NewPredicateFuncs(func(obj client.Object) bool { - annotations := obj.GetAnnotations() - if annotations == nil { - return true - } + return predicate.NewPredicateFuncs( + func(obj client.Object) bool { + annotations := obj.GetAnnotations() + if annotations == nil { + return true + } - // Check for ignore annotation - return annotations[cfg.Annotations.Ignore] != "true" - }) + return annotations[cfg.Annotations.Ignore] != "true" + }, + ) } // CombinedPredicates combines multiple predicates with AND logic. diff --git a/internal/pkg/reload/predicate_test.go b/internal/pkg/reload/predicate_test.go index 285d367b..1ccb0475 100644 --- a/internal/pkg/reload/predicate_test.go +++ b/internal/pkg/reload/predicate_test.go @@ -44,25 +44,27 @@ func TestNamespaceFilterPredicate_Create(t *testing.T) { } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := config.NewDefault() - cfg.IgnoredNamespaces = tt.ignoredNamespaces - predicate := NamespaceFilterPredicate(cfg) + t.Run( + tt.name, func(t *testing.T) { + cfg := config.NewDefault() + cfg.IgnoredNamespaces = tt.ignoredNamespaces + predicate := NamespaceFilterPredicate(cfg) - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: tt.eventNamespace, - }, - } + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: tt.eventNamespace, + }, + } - e := event.CreateEvent{Object: cm} - got := predicate.Create(e) + e := event.CreateEvent{Object: cm} + got := predicate.Create(e) - if got != tt.wantAllow { - t.Errorf("Create() = %v, want %v", got, tt.wantAllow) - } - }) + if got != tt.wantAllow { + t.Errorf("Create() = %v, want %v", got, tt.wantAllow) + } + }, + ) } } @@ -172,36 +174,37 @@ func TestLabelSelectorPredicate_Create(t *testing.T) { } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := config.NewDefault() - selector, err := labels.Parse(tt.selector) - if err != nil { - t.Fatalf("Failed to parse selector: %v", err) - } - cfg.ResourceSelectors = []labels.Selector{selector} - predicate := LabelSelectorPredicate(cfg) + t.Run( + tt.name, func(t *testing.T) { + cfg := config.NewDefault() + selector, err := labels.Parse(tt.selector) + if err != nil { + t.Fatalf("Failed to parse selector: %v", err) + } + cfg.ResourceSelectors = []labels.Selector{selector} + predicate := LabelSelectorPredicate(cfg) - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "default", - Labels: tt.objectLabels, - }, - } + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + Labels: tt.objectLabels, + }, + } - e := event.CreateEvent{Object: cm} - got := predicate.Create(e) + e := event.CreateEvent{Object: cm} + got := predicate.Create(e) - if got != tt.wantAllow { - t.Errorf("Create() = %v, want %v", got, tt.wantAllow) - } - }) + if got != tt.wantAllow { + t.Errorf("Create() = %v, want %v", got, tt.wantAllow) + } + }, + ) } } func TestLabelSelectorPredicate_NoSelectors(t *testing.T) { cfg := config.NewDefault() - // No selectors configured predicate := LabelSelectorPredicate(cfg) cm := &corev1.ConfigMap{ @@ -253,22 +256,24 @@ func TestLabelSelectorPredicate_MultipleSelectors(t *testing.T) { } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "default", - Labels: tt.labels, - }, - } + t.Run( + tt.name, func(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + Labels: tt.labels, + }, + } - e := event.CreateEvent{Object: cm} - got := predicate.Create(e) + e := event.CreateEvent{Object: cm} + got := predicate.Create(e) - if got != tt.wantAllow { - t.Errorf("Create() = %v, want %v", got, tt.wantAllow) - } - }) + if got != tt.wantAllow { + t.Errorf("Create() = %v, want %v", got, tt.wantAllow) + } + }, + ) } } @@ -392,34 +397,35 @@ func TestCombinedFiltering(t *testing.T) { } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: tt.namespace, - Labels: tt.labels, - }, - } + t.Run( + tt.name, func(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: tt.namespace, + Labels: tt.labels, + }, + } - e := event.CreateEvent{Object: cm} + e := event.CreateEvent{Object: cm} - gotNS := nsPredicate.Create(e) - if gotNS != tt.wantNSAllow { - t.Errorf("Namespace predicate Create() = %v, want %v", gotNS, tt.wantNSAllow) - } + gotNS := nsPredicate.Create(e) + if gotNS != tt.wantNSAllow { + t.Errorf("Namespace predicate Create() = %v, want %v", gotNS, tt.wantNSAllow) + } - gotLabel := labelPredicate.Create(e) - if gotLabel != tt.wantLabelAllow { - t.Errorf("Label predicate Create() = %v, want %v", gotLabel, tt.wantLabelAllow) - } + gotLabel := labelPredicate.Create(e) + if gotLabel != tt.wantLabelAllow { + t.Errorf("Label predicate Create() = %v, want %v", gotLabel, tt.wantLabelAllow) + } - // Both must be true for the event to pass through - combinedAllow := gotNS && gotLabel - expectedCombined := tt.wantNSAllow && tt.wantLabelAllow - if combinedAllow != expectedCombined { - t.Errorf("Combined allow = %v, want %v", combinedAllow, expectedCombined) - } - }) + combinedAllow := gotNS && gotLabel + expectedCombined := tt.wantNSAllow && tt.wantLabelAllow + if combinedAllow != expectedCombined { + t.Errorf("Combined allow = %v, want %v", combinedAllow, expectedCombined) + } + }, + ) } } @@ -449,7 +455,6 @@ func TestFilteringWithSecrets(t *testing.T) { func TestExistsLabelSelector(t *testing.T) { cfg := config.NewDefault() - // Selector that checks if label exists (any value) selector, _ := labels.Parse("managed") cfg.ResourceSelectors = []labels.Selector{selector} predicate := LabelSelectorPredicate(cfg) @@ -482,22 +487,24 @@ func TestExistsLabelSelector(t *testing.T) { } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "default", - Labels: tt.labels, - }, - } + t.Run( + tt.name, func(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + Labels: tt.labels, + }, + } - e := event.CreateEvent{Object: cm} - got := predicate.Create(e) + e := event.CreateEvent{Object: cm} + got := predicate.Create(e) - if got != tt.wantAllow { - t.Errorf("Create() = %v, want %v", got, tt.wantAllow) - } - }) + if got != tt.wantAllow { + t.Errorf("Create() = %v, want %v", got, tt.wantAllow) + } + }, + ) } } @@ -549,27 +556,29 @@ func TestNamespaceFilterPredicateWithCache(t *testing.T) { } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := config.NewDefault() - cfg.IgnoredNamespaces = tt.ignoredNamespaces + t.Run( + tt.name, func(t *testing.T) { + cfg := config.NewDefault() + cfg.IgnoredNamespaces = tt.ignoredNamespaces - cache := &mockNamespaceChecker{allowed: tt.cacheAllowed} - predicate := NamespaceFilterPredicateWithCache(cfg, cache) + cache := &mockNamespaceChecker{allowed: tt.cacheAllowed} + predicate := NamespaceFilterPredicateWithCache(cfg, cache) - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: tt.eventNamespace, - }, - } + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: tt.eventNamespace, + }, + } - e := event.CreateEvent{Object: cm} - got := predicate.Create(e) + e := event.CreateEvent{Object: cm} + got := predicate.Create(e) - if got != tt.wantAllow { - t.Errorf("Create() = %v, want %v", got, tt.wantAllow) - } - }) + if got != tt.wantAllow { + t.Errorf("Create() = %v, want %v", got, tt.wantAllow) + } + }, + ) } } @@ -577,7 +586,6 @@ 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 { @@ -590,21 +598,23 @@ func TestNamespaceFilterPredicateWithCache_NilCache(t *testing.T) { } for _, tt := range tests { - t.Run(tt.namespace, func(t *testing.T) { - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: tt.namespace, - }, - } + 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) + 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) - } - }) + if got != tt.wantAllow { + t.Errorf("Create() = %v, want %v for namespace %s", got, tt.wantAllow, tt.namespace) + } + }, + ) } } @@ -650,22 +660,24 @@ func TestIgnoreAnnotationPredicate_Create(t *testing.T) { } 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, - }, - } + 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) + e := event.CreateEvent{Object: cm} + got := predicate.Create(e) - if got != tt.wantAllow { - t.Errorf("Create() = %v, want %v", got, tt.wantAllow) - } - }) + if got != tt.wantAllow { + t.Errorf("Create() = %v, want %v", got, tt.wantAllow) + } + }, + ) } } @@ -688,7 +700,6 @@ func TestIgnoreAnnotationPredicate_AllEventTypes(t *testing.T) { }, } - // Test Update if predicate.Update(event.UpdateEvent{ObjectNew: ignoredCM}) { t.Error("Update() should block ignored resource") } @@ -696,7 +707,6 @@ func TestIgnoreAnnotationPredicate_AllEventTypes(t *testing.T) { t.Error("Update() should allow non-ignored resource") } - // Test Delete if predicate.Delete(event.DeleteEvent{Object: ignoredCM}) { t.Error("Delete() should block ignored resource") } @@ -704,7 +714,6 @@ func TestIgnoreAnnotationPredicate_AllEventTypes(t *testing.T) { t.Error("Delete() should allow non-ignored resource") } - // Test Generic if predicate.Generic(event.GenericEvent{Object: ignoredCM}) { t.Error("Generic() should block ignored resource") } @@ -755,22 +764,24 @@ func TestCombinedPredicates(t *testing.T) { } 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, - }, - } + 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) + e := event.CreateEvent{Object: cm} + got := combined.Create(e) - if got != tt.wantAllow { - t.Errorf("Create() = %v, want %v", got, tt.wantAllow) - } - }) + if got != tt.wantAllow { + t.Errorf("Create() = %v, want %v", got, tt.wantAllow) + } + }, + ) } } @@ -792,13 +803,11 @@ func TestConfigMapPredicates_Update(t *testing.T) { 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") @@ -810,7 +819,6 @@ func TestConfigMapPredicates_InvalidTypes(t *testing.T) { hasher := NewHasher() predicate := ConfigMapPredicates(cfg, hasher) - // Test with non-ConfigMap types secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, } @@ -818,13 +826,11 @@ func TestConfigMapPredicates_InvalidTypes(t *testing.T) { 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") @@ -842,17 +848,14 @@ func TestConfigMapPredicates_CreateDeleteGeneric(t *testing.T) { 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") } @@ -876,13 +879,11 @@ func TestSecretPredicates_Update(t *testing.T) { 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") @@ -894,7 +895,6 @@ func TestSecretPredicates_InvalidTypes(t *testing.T) { hasher := NewHasher() predicate := SecretPredicates(cfg, hasher) - // Test with non-Secret types cm := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, } @@ -902,13 +902,11 @@ func TestSecretPredicates_InvalidTypes(t *testing.T) { 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") @@ -918,7 +916,6 @@ func TestSecretPredicates_InvalidTypes(t *testing.T) { func TestLabelsSet(t *testing.T) { ls := LabelsSet{"app": "test", "env": "prod"} - // Test Has if !ls.Has("app") { t.Error("Has(app) should return true") } @@ -926,7 +923,6 @@ func TestLabelsSet(t *testing.T) { t.Error("Has(nonexistent) should return false") } - // Test Get if ls.Get("app") != "test" { t.Errorf("Get(app) = %v, want test", ls.Get("app")) } diff --git a/internal/pkg/reload/resource_type_test.go b/internal/pkg/reload/resource_type_test.go index 428f29ed..e577e82b 100644 --- a/internal/pkg/reload/resource_type_test.go +++ b/internal/pkg/reload/resource_type_test.go @@ -16,21 +16,13 @@ func TestResourceType_Kind(t *testing.T) { } 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) + 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) + } + }, + ) } } diff --git a/internal/pkg/reload/service_test.go b/internal/pkg/reload/service_test.go index daea21ce..cd94873f 100644 --- a/internal/pkg/reload/service_test.go +++ b/internal/pkg/reload/service_test.go @@ -16,9 +16,11 @@ func TestService_ProcessConfigMap_AutoReload(t *testing.T) { svc := NewService(cfg) // Create a deployment with auto annotation that uses the configmap - deploy := createTestDeployment("test-deploy", "default", map[string]string{ - "reloader.stakater.com/auto": "true", - }) + deploy := createTestDeployment( + "test-deploy", "default", map[string]string{ + "reloader.stakater.com/auto": "true", + }, + ) deploy.Spec.Template.Spec.Volumes = []corev1.Volume{ { Name: "config-vol", @@ -74,10 +76,11 @@ func TestService_ProcessConfigMap_ExplicitAnnotation(t *testing.T) { cfg := config.NewDefault() svc := NewService(cfg) - // Create a deployment with explicit configmap annotation - deploy := createTestDeployment("test-deploy", "default", map[string]string{ - "configmap.reloader.stakater.com/reload": "test-cm", - }) + deploy := createTestDeployment( + "test-deploy", "default", map[string]string{ + "configmap.reloader.stakater.com/reload": "test-cm", + }, + ) workloads := []workload.WorkloadAccessor{ workload.NewDeploymentWorkload(deploy), @@ -118,9 +121,11 @@ func TestService_ProcessConfigMap_IgnoredResource(t *testing.T) { svc := NewService(cfg) // Create a deployment with auto annotation - deploy := createTestDeployment("test-deploy", "default", map[string]string{ - "reloader.stakater.com/auto": "true", - }) + deploy := createTestDeployment( + "test-deploy", "default", map[string]string{ + "reloader.stakater.com/auto": "true", + }, + ) deploy.Spec.Template.Spec.Volumes = []corev1.Volume{ { Name: "config-vol", @@ -172,9 +177,11 @@ func TestService_ProcessSecret_AutoReload(t *testing.T) { svc := NewService(cfg) // Create a deployment with auto annotation that uses the secret - deploy := createTestDeployment("test-deploy", "default", map[string]string{ - "reloader.stakater.com/auto": "true", - }) + deploy := createTestDeployment( + "test-deploy", "default", map[string]string{ + "reloader.stakater.com/auto": "true", + }, + ) deploy.Spec.Template.Spec.Volumes = []corev1.Volume{ { Name: "secret-vol", @@ -226,9 +233,11 @@ func TestService_ProcessConfigMap_DeleteEvent(t *testing.T) { svc := NewService(cfg) // Create a deployment with explicit configmap annotation - deploy := createTestDeployment("test-deploy", "default", map[string]string{ - "configmap.reloader.stakater.com/reload": "test-cm", - }) + deploy := createTestDeployment( + "test-deploy", "default", map[string]string{ + "configmap.reloader.stakater.com/reload": "test-cm", + }, + ) workloads := []workload.WorkloadAccessor{ workload.NewDeploymentWorkload(deploy), @@ -267,9 +276,11 @@ func TestService_ProcessConfigMap_DeleteEventDisabled(t *testing.T) { cfg.ReloadOnDelete = false // Disabled by default svc := NewService(cfg) - deploy := createTestDeployment("test-deploy", "default", map[string]string{ - "configmap.reloader.stakater.com/reload": "test-cm", - }) + deploy := createTestDeployment( + "test-deploy", "default", map[string]string{ + "configmap.reloader.stakater.com/reload": "test-cm", + }, + ) workloads := []workload.WorkloadAccessor{ workload.NewDeploymentWorkload(deploy), @@ -440,9 +451,11 @@ func TestService_ProcessConfigMap_MultipleWorkloads(t *testing.T) { svc := NewService(cfg) // Create multiple workloads - deploy1 := createTestDeployment("deploy1", "default", map[string]string{ - "reloader.stakater.com/auto": "true", - }) + deploy1 := createTestDeployment( + "deploy1", "default", map[string]string{ + "reloader.stakater.com/auto": "true", + }, + ) deploy1.Spec.Template.Spec.Volumes = []corev1.Volume{ { Name: "config-vol", @@ -456,9 +469,11 @@ func TestService_ProcessConfigMap_MultipleWorkloads(t *testing.T) { }, } - deploy2 := createTestDeployment("deploy2", "default", map[string]string{ - "reloader.stakater.com/auto": "true", - }) + deploy2 := createTestDeployment( + "deploy2", "default", map[string]string{ + "reloader.stakater.com/auto": "true", + }, + ) deploy2.Spec.Template.Spec.Volumes = []corev1.Volume{ { Name: "config-vol", @@ -473,9 +488,11 @@ func TestService_ProcessConfigMap_MultipleWorkloads(t *testing.T) { } // Deploy3 doesn't use the configmap - deploy3 := createTestDeployment("deploy3", "default", map[string]string{ - "reloader.stakater.com/auto": "true", - }) + deploy3 := createTestDeployment( + "deploy3", "default", map[string]string{ + "reloader.stakater.com/auto": "true", + }, + ) workloads := []workload.WorkloadAccessor{ workload.NewDeploymentWorkload(deploy1), @@ -521,9 +538,11 @@ func TestService_ProcessConfigMap_DifferentNamespaces(t *testing.T) { svc := NewService(cfg) // Create deployments in different namespaces - deploy1 := createTestDeployment("deploy1", "namespace-a", map[string]string{ - "reloader.stakater.com/auto": "true", - }) + deploy1 := createTestDeployment( + "deploy1", "namespace-a", map[string]string{ + "reloader.stakater.com/auto": "true", + }, + ) deploy1.Spec.Template.Spec.Volumes = []corev1.Volume{ { Name: "config-vol", @@ -537,9 +556,11 @@ func TestService_ProcessConfigMap_DifferentNamespaces(t *testing.T) { }, } - deploy2 := createTestDeployment("deploy2", "namespace-b", map[string]string{ - "reloader.stakater.com/auto": "true", - }) + deploy2 := createTestDeployment( + "deploy2", "namespace-b", map[string]string{ + "reloader.stakater.com/auto": "true", + }, + ) deploy2.Spec.Template.Spec.Volumes = []corev1.Volume{ { Name: "config-vol", @@ -624,17 +645,19 @@ func TestService_shouldProcessEvent(t *testing.T) { } 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) + 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) - } - }) + result := svc.shouldProcessEvent(tt.eventType) + if result != tt.expected { + t.Errorf("shouldProcessEvent(%s) = %v, want %v", tt.eventType, result, tt.expected) + } + }, + ) } } @@ -713,12 +736,14 @@ func TestService_findVolumeUsingResource_ConfigMap(t *testing.T) { } 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) - } - }) + 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) + } + }, + ) } } @@ -786,12 +811,14 @@ func TestService_findVolumeUsingResource_Secret(t *testing.T) { } 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) - } - }) + 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) + } + }, + ) } } @@ -860,20 +887,22 @@ func TestService_findContainerWithVolumeMount(t *testing.T) { } 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) + 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) + } } - } else { - if got != nil { - t.Errorf("Expected nil, got container %q", got.Name) - } - } - }) + }, + ) } } @@ -882,11 +911,11 @@ func TestService_findContainerWithEnvRef_ConfigMap(t *testing.T) { svc := NewService(cfg) tests := []struct { - name string - containers []corev1.Container + name string + containers []corev1.Container resourceName string - wantName string - shouldMatch bool + wantName string + shouldMatch bool }{ { name: "container with ConfigMapKeyRef", @@ -935,7 +964,7 @@ func TestService_findContainerWithEnvRef_ConfigMap(t *testing.T) { Name: "app", Env: []corev1.EnvVar{ { - Name: "SIMPLE_VAR", + Name: "SIMPLE_VAR", Value: "value", }, }, @@ -960,20 +989,22 @@ func TestService_findContainerWithEnvRef_ConfigMap(t *testing.T) { } 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) + 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) + } } - } else { - if got != nil { - t.Errorf("Expected nil, got container %q", got.Name) - } - } - }) + }, + ) } } @@ -982,11 +1013,11 @@ func TestService_findContainerWithEnvRef_Secret(t *testing.T) { svc := NewService(cfg) tests := []struct { - name string - containers []corev1.Container + name string + containers []corev1.Container resourceName string - wantName string - shouldMatch bool + wantName string + shouldMatch bool }{ { name: "container with SecretKeyRef", @@ -1047,20 +1078,22 @@ func TestService_findContainerWithEnvRef_Secret(t *testing.T) { } 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) + 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) + } } - } else { - if got != nil { - t.Errorf("Expected nil, got container %q", got.Name) - } - } - }) + }, + ) } } @@ -1302,9 +1335,11 @@ func TestService_ProcessCreateEventDisabled(t *testing.T) { cfg.ReloadOnCreate = false svc := NewService(cfg) - deploy := createTestDeployment("test", "default", map[string]string{ - "reloader.stakater.com/auto": "true", - }) + deploy := createTestDeployment( + "test", "default", map[string]string{ + "reloader.stakater.com/auto": "true", + }, + ) workloads := []workload.WorkloadAccessor{workload.NewDeploymentWorkload(deploy)} cm := &corev1.ConfigMap{ diff --git a/internal/pkg/testutil/rand.go b/internal/pkg/testutil/rand.go new file mode 100644 index 00000000..a20d8ad0 --- /dev/null +++ b/internal/pkg/testutil/rand.go @@ -0,0 +1,21 @@ +package testutil + +import ( + "math/rand" + "time" +) + +const letterBytes = "abcdefghijklmnopqrstuvwxyz" + +func init() { + rand.Seed(time.Now().UnixNano()) +} + +// RandSeq generates a random string of the specified length. +func RandSeq(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = letterBytes[rand.Intn(len(letterBytes))] + } + return string(b) +} diff --git a/internal/pkg/testutil/testutil.go b/internal/pkg/testutil/testutil.go new file mode 100644 index 00000000..9dcde17e --- /dev/null +++ b/internal/pkg/testutil/testutil.go @@ -0,0 +1,465 @@ +package testutil + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "fmt" + "time" + + "github.com/stakater/Reloader/internal/pkg/config" + 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" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes" +) + +const ( + // ConfigmapResourceType represents ConfigMap resource type + ConfigmapResourceType = "configmap" + // SecretResourceType represents Secret resource type + SecretResourceType = "secret" +) + +// CreateNamespace creates a namespace with the given name. +func CreateNamespace(name string, client kubernetes.Interface) error { + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + } + _, err := client.CoreV1().Namespaces().Create(context.Background(), ns, metav1.CreateOptions{}) + return err +} + +// DeleteNamespace deletes the namespace with the given name. +func DeleteNamespace(name string, client kubernetes.Interface) error { + return client.CoreV1().Namespaces().Delete(context.Background(), name, metav1.DeleteOptions{}) +} + +// CreateConfigMap creates a ConfigMap with the given name and data. +func CreateConfigMap(client kubernetes.Interface, namespace, name, data string) (*corev1.ConfigMap, error) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Data: map[string]string{ + "url": data, + }, + } + return client.CoreV1().ConfigMaps(namespace).Create(context.Background(), cm, metav1.CreateOptions{}) +} + +// UpdateConfigMap updates the ConfigMap with new label and/or data. +func UpdateConfigMap(cm *corev1.ConfigMap, namespace, name, label, data string) error { + if label != "" { + if cm.Labels == nil { + cm.Labels = make(map[string]string) + } + cm.Labels["test-label"] = label + } + if data != "" { + cm.Data["url"] = data + } + // Note: caller must have a client to update + return nil +} + +// UpdateConfigMapWithClient updates the ConfigMap with new label and/or data. +func UpdateConfigMapWithClient(client kubernetes.Interface, namespace, name, label, data string) error { + ctx := context.Background() + cm, err := client.CoreV1().ConfigMaps(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return err + } + if label != "" { + if cm.Labels == nil { + cm.Labels = make(map[string]string) + } + cm.Labels["test-label"] = label + } + if data != "" { + cm.Data["url"] = data + } + _, err = client.CoreV1().ConfigMaps(namespace).Update(ctx, cm, metav1.UpdateOptions{}) + return err +} + +// DeleteConfigMap deletes the ConfigMap with the given name. +func DeleteConfigMap(client kubernetes.Interface, namespace, name string) error { + return client.CoreV1().ConfigMaps(namespace).Delete(context.Background(), name, metav1.DeleteOptions{}) +} + +// CreateSecret creates a Secret with the given name and data. +func CreateSecret(client kubernetes.Interface, namespace, name, data string) (*corev1.Secret, error) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Data: map[string][]byte{ + "password": []byte(data), + }, + } + return client.CoreV1().Secrets(namespace).Create(context.Background(), secret, metav1.CreateOptions{}) +} + +// UpdateSecretWithClient updates the Secret with new label and/or data. +func UpdateSecretWithClient(client kubernetes.Interface, namespace, name, label, data string) error { + ctx := context.Background() + secret, err := client.CoreV1().Secrets(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return err + } + if label != "" { + if secret.Labels == nil { + secret.Labels = make(map[string]string) + } + secret.Labels["test-label"] = label + } + if data != "" { + secret.Data["password"] = []byte(data) + } + _, err = client.CoreV1().Secrets(namespace).Update(ctx, secret, metav1.UpdateOptions{}) + return err +} + +// DeleteSecret deletes the Secret with the given name. +func DeleteSecret(client kubernetes.Interface, namespace, name string) error { + return client.CoreV1().Secrets(namespace).Delete(context.Background(), name, metav1.DeleteOptions{}) +} + +// CreateDeployment creates a Deployment that references a ConfigMap/Secret. +func CreateDeployment(client kubernetes.Interface, name, namespace string, useConfigMap bool, annotations map[string]string) (*appsv1.Deployment, error) { + replicas := int32(1) + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Annotations: annotations, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": name}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": name}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + Image: "busybox:1.36", + Command: []string{"sh", "-c", "while true; do sleep 3600; done"}, + }, + }, + }, + }, + }, + } + + if useConfigMap { + deployment.Spec.Template.Spec.Containers[0].EnvFrom = []corev1.EnvFromSource{ + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: name, + }, + }, + }, + } + } else { + deployment.Spec.Template.Spec.Containers[0].EnvFrom = []corev1.EnvFromSource{ + { + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: name, + }, + }, + }, + } + } + + return client.AppsV1().Deployments(namespace).Create(context.Background(), deployment, metav1.CreateOptions{}) +} + +// DeleteDeployment deletes the Deployment with the given name. +func DeleteDeployment(client kubernetes.Interface, namespace, name string) error { + return client.AppsV1().Deployments(namespace).Delete(context.Background(), name, metav1.DeleteOptions{}) +} + +// CreateDaemonSet creates a DaemonSet that references a ConfigMap/Secret. +func CreateDaemonSet(client kubernetes.Interface, name, namespace string, useConfigMap bool, annotations map[string]string) (*appsv1.DaemonSet, error) { + daemonset := &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Annotations: annotations, + }, + Spec: appsv1.DaemonSetSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": name}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": name}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + Image: "busybox:1.36", + Command: []string{"sh", "-c", "while true; do sleep 3600; done"}, + }, + }, + }, + }, + }, + } + + if useConfigMap { + daemonset.Spec.Template.Spec.Containers[0].EnvFrom = []corev1.EnvFromSource{ + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: name, + }, + }, + }, + } + } else { + daemonset.Spec.Template.Spec.Containers[0].EnvFrom = []corev1.EnvFromSource{ + { + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: name, + }, + }, + }, + } + } + + return client.AppsV1().DaemonSets(namespace).Create(context.Background(), daemonset, metav1.CreateOptions{}) +} + +// DeleteDaemonSet deletes the DaemonSet with the given name. +func DeleteDaemonSet(client kubernetes.Interface, namespace, name string) error { + return client.AppsV1().DaemonSets(namespace).Delete(context.Background(), name, metav1.DeleteOptions{}) +} + +// CreateStatefulSet creates a StatefulSet that references a ConfigMap/Secret. +func CreateStatefulSet(client kubernetes.Interface, name, namespace string, useConfigMap bool, annotations map[string]string) (*appsv1.StatefulSet, error) { + replicas := int32(1) + statefulset := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Annotations: annotations, + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": name}, + }, + ServiceName: name, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": name}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + Image: "busybox:1.36", + Command: []string{"sh", "-c", "while true; do sleep 3600; done"}, + }, + }, + }, + }, + }, + } + + if useConfigMap { + statefulset.Spec.Template.Spec.Containers[0].EnvFrom = []corev1.EnvFromSource{ + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: name, + }, + }, + }, + } + } else { + statefulset.Spec.Template.Spec.Containers[0].EnvFrom = []corev1.EnvFromSource{ + { + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: name, + }, + }, + }, + } + } + + return client.AppsV1().StatefulSets(namespace).Create(context.Background(), statefulset, metav1.CreateOptions{}) +} + +// DeleteStatefulSet deletes the StatefulSet with the given name. +func DeleteStatefulSet(client kubernetes.Interface, namespace, name string) error { + return client.AppsV1().StatefulSets(namespace).Delete(context.Background(), name, metav1.DeleteOptions{}) +} + +// CreateCronJob creates a CronJob that references a ConfigMap/Secret. +func CreateCronJob(client kubernetes.Interface, name, namespace string, useConfigMap bool, annotations map[string]string) (*batchv1.CronJob, error) { + cronjob := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Annotations: annotations, + }, + Spec: batchv1.CronJobSpec{ + Schedule: "*/5 * * * *", + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyOnFailure, + Containers: []corev1.Container{ + { + Name: "main", + Image: "busybox:1.36", + Command: []string{"sh", "-c", "echo hello"}, + }, + }, + }, + }, + }, + }, + }, + } + + if useConfigMap { + cronjob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].EnvFrom = []corev1.EnvFromSource{ + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: name, + }, + }, + }, + } + } else { + cronjob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].EnvFrom = []corev1.EnvFromSource{ + { + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: name, + }, + }, + }, + } + } + + return client.BatchV1().CronJobs(namespace).Create(context.Background(), cronjob, metav1.CreateOptions{}) +} + +// DeleteCronJob deletes the CronJob with the given name. +func DeleteCronJob(client kubernetes.Interface, namespace, name string) error { + return client.BatchV1().CronJobs(namespace).Delete(context.Background(), name, metav1.DeleteOptions{}) +} + +// ConvertResourceToSHA converts a resource data to SHA256 hash. +func ConvertResourceToSHA(resourceType, namespace, name, data string) string { + content := fmt.Sprintf("%s/%s/%s:%s", resourceType, namespace, name, data) + hash := sha256.Sum256([]byte(content)) + return base64.StdEncoding.EncodeToString(hash[:]) +} + +// WaitForDeploymentAnnotation waits for a deployment to have the specified annotation value. +func WaitForDeploymentAnnotation(client kubernetes.Interface, namespace, name, annotation, expectedValue string, timeout time.Duration) error { + return wait.PollImmediate(time.Second, timeout, func() (bool, error) { + deployment, err := client.AppsV1().Deployments(namespace).Get(context.Background(), name, metav1.GetOptions{}) + if err != nil { + return false, nil // Keep waiting + } + value, ok := deployment.Spec.Template.Annotations[annotation] + if !ok { + return false, nil // Keep waiting + } + return value == expectedValue, nil + }) +} + +// WaitForDeploymentReloadedAnnotation waits for a deployment to have any reloaded annotation. +func WaitForDeploymentReloadedAnnotation(client kubernetes.Interface, namespace, name string, cfg *config.Config, timeout time.Duration) (bool, error) { + var found bool + err := wait.PollImmediate(time.Second, timeout, func() (bool, error) { + deployment, err := client.AppsV1().Deployments(namespace).Get(context.Background(), name, metav1.GetOptions{}) + if err != nil { + return false, nil // Keep waiting + } + // Check for the last-reloaded-from annotation in pod template + if deployment.Spec.Template.Annotations != nil { + if _, ok := deployment.Spec.Template.Annotations[cfg.Annotations.LastReloadedFrom]; ok { + found = true + return true, nil + } + } + return false, nil + }) + if err == wait.ErrWaitTimeout { + return found, nil + } + return found, err +} + +// WaitForDaemonSetReloadedAnnotation waits for a daemonset to have any reloaded annotation. +func WaitForDaemonSetReloadedAnnotation(client kubernetes.Interface, namespace, name string, cfg *config.Config, timeout time.Duration) (bool, error) { + var found bool + err := wait.PollImmediate(time.Second, timeout, func() (bool, error) { + daemonset, err := client.AppsV1().DaemonSets(namespace).Get(context.Background(), name, metav1.GetOptions{}) + if err != nil { + return false, nil // Keep waiting + } + // Check for the last-reloaded-from annotation in pod template + if daemonset.Spec.Template.Annotations != nil { + if _, ok := daemonset.Spec.Template.Annotations[cfg.Annotations.LastReloadedFrom]; ok { + found = true + return true, nil + } + } + return false, nil + }) + if err == wait.ErrWaitTimeout { + return found, nil + } + return found, err +} + +// WaitForStatefulSetReloadedAnnotation waits for a statefulset to have any reloaded annotation. +func WaitForStatefulSetReloadedAnnotation(client kubernetes.Interface, namespace, name string, cfg *config.Config, timeout time.Duration) (bool, error) { + var found bool + err := wait.PollImmediate(time.Second, timeout, func() (bool, error) { + statefulset, err := client.AppsV1().StatefulSets(namespace).Get(context.Background(), name, metav1.GetOptions{}) + if err != nil { + return false, nil // Keep waiting + } + // Check for the last-reloaded-from annotation in pod template + if statefulset.Spec.Template.Annotations != nil { + if _, ok := statefulset.Spec.Template.Annotations[cfg.Annotations.LastReloadedFrom]; ok { + found = true + return true, nil + } + } + return false, nil + }) + if err == wait.ErrWaitTimeout { + return found, nil + } + return found, err +} diff --git a/internal/pkg/workload/registry.go b/internal/pkg/workload/registry.go index 920a7eab..696b11c5 100644 --- a/internal/pkg/workload/registry.go +++ b/internal/pkg/workload/registry.go @@ -84,18 +84,18 @@ func (r *Registry) FromObject(obj client.Object) (WorkloadAccessor, error) { // kindAliases maps string representations to Kind constants. // Supports lowercase, title case, and plural forms for user convenience. var kindAliases = map[string]Kind{ - "deployment": KindDeployment, - "deployments": KindDeployment, - "daemonset": KindDaemonSet, - "daemonsets": KindDaemonSet, - "statefulset": KindStatefulSet, + "deployment": KindDeployment, + "deployments": KindDeployment, + "daemonset": KindDaemonSet, + "daemonsets": KindDaemonSet, + "statefulset": KindStatefulSet, "statefulsets": KindStatefulSet, - "rollout": KindArgoRollout, - "rollouts": KindArgoRollout, - "job": KindJob, - "jobs": KindJob, - "cronjob": KindCronJob, - "cronjobs": KindCronJob, + "rollout": KindArgoRollout, + "rollouts": KindArgoRollout, + "job": KindJob, + "jobs": KindJob, + "cronjob": KindCronJob, + "cronjobs": KindCronJob, } // KindFromString converts a string to a Kind. diff --git a/internal/pkg/workload/registry_test.go b/internal/pkg/workload/registry_test.go index 0bb47d14..4ebfb870 100644 --- a/internal/pkg/workload/registry_test.go +++ b/internal/pkg/workload/registry_test.go @@ -61,8 +61,8 @@ func TestRegistry_ListerFor_AllKinds(t *testing.T) { r := NewRegistry(true) tests := []struct { - kind Kind - wantNil bool + kind Kind + wantNil bool }{ {KindDeployment, false}, {KindDaemonSet, false}, diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go new file mode 100644 index 00000000..32106d49 --- /dev/null +++ b/test/e2e/e2e_test.go @@ -0,0 +1,519 @@ +// Package e2e contains end-to-end tests for Reloader. +// These tests run against a real Kubernetes cluster (or envtest). +// +// To run these tests against a real cluster: +// +// KUBECONFIG=~/.kube/config go test -v ./test/e2e/... -count=1 +// +// To skip these tests when running unit tests: +// +// go test -v ./... -short +package e2e + +import ( + "context" + "flag" + "log" + "os" + "testing" + "time" + + "github.com/go-logr/zerologr" + "github.com/rs/zerolog" + "github.com/stakater/Reloader/internal/pkg/config" + "github.com/stakater/Reloader/internal/pkg/controller" + "github.com/stakater/Reloader/internal/pkg/metrics" + "github.com/stakater/Reloader/internal/pkg/testutil" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + ctrl "sigs.k8s.io/controller-runtime" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/manager" +) + +const ( + testNamespacePrefix = "test-reloader-e2e-" + waitTimeout = 30 * time.Second + setupDelay = 2 * time.Second + negativeTestTimeout = 5 * time.Second +) + +var ( + k8sClient kubernetes.Interface + cfg *config.Config + namespace string + skipE2ETests bool + cancelManager context.CancelFunc + restCfg *rest.Config +) + +// testFixture provides a clean way to set up and tear down test resources. +type testFixture struct { + t *testing.T + name string + configMaps []string + secrets []string + workloads []workloadInfo +} + +type workloadInfo struct { + name string + kind string // "deployment", "daemonset", "statefulset" +} + +// newFixture creates a new test fixture with a unique name prefix. +func newFixture(t *testing.T, prefix string) *testFixture { + t.Helper() + skipIfNoCluster(t) + return &testFixture{ + t: t, + name: prefix + "-" + testutil.RandSeq(5), + } +} + +// createConfigMap creates a ConfigMap and registers it for cleanup. +func (f *testFixture) createConfigMap(name, data string) { + f.t.Helper() + _, err := testutil.CreateConfigMap(k8sClient, namespace, name, data) + if err != nil { + f.t.Fatalf("Failed to create ConfigMap %s: %v", name, err) + } + f.configMaps = append(f.configMaps, name) +} + +// createSecret creates a Secret and registers it for cleanup. +func (f *testFixture) createSecret(name, data string) { + f.t.Helper() + _, err := testutil.CreateSecret(k8sClient, namespace, name, data) + if err != nil { + f.t.Fatalf("Failed to create Secret %s: %v", name, err) + } + f.secrets = append(f.secrets, name) +} + +// createDeployment creates a Deployment and registers it for cleanup. +func (f *testFixture) createDeployment(name string, useConfigMap bool, annotations map[string]string) { + f.t.Helper() + _, err := testutil.CreateDeployment(k8sClient, name, namespace, useConfigMap, annotations) + if err != nil { + f.t.Fatalf("Failed to create Deployment %s: %v", name, err) + } + f.workloads = append(f.workloads, workloadInfo{name: name, kind: "deployment"}) +} + +// createDaemonSet creates a DaemonSet and registers it for cleanup. +func (f *testFixture) createDaemonSet(name string, useConfigMap bool, annotations map[string]string) { + f.t.Helper() + _, err := testutil.CreateDaemonSet(k8sClient, name, namespace, useConfigMap, annotations) + if err != nil { + f.t.Fatalf("Failed to create DaemonSet %s: %v", name, err) + } + f.workloads = append(f.workloads, workloadInfo{name: name, kind: "daemonset"}) +} + +// createStatefulSet creates a StatefulSet and registers it for cleanup. +func (f *testFixture) createStatefulSet(name string, useConfigMap bool, annotations map[string]string) { + f.t.Helper() + _, err := testutil.CreateStatefulSet(k8sClient, name, namespace, useConfigMap, annotations) + if err != nil { + f.t.Fatalf("Failed to create StatefulSet %s: %v", name, err) + } + f.workloads = append(f.workloads, workloadInfo{name: name, kind: "statefulset"}) +} + +// waitForReady waits for all workloads to be ready. +func (f *testFixture) waitForReady() { + time.Sleep(setupDelay) +} + +// updateConfigMap updates a ConfigMap's data. +func (f *testFixture) updateConfigMap(name, data string) { + f.t.Helper() + if err := testutil.UpdateConfigMapWithClient(k8sClient, namespace, name, "", data); err != nil { + f.t.Fatalf("Failed to update ConfigMap %s: %v", name, err) + } +} + +// updateConfigMapLabel updates only a ConfigMap's label (not data). +func (f *testFixture) updateConfigMapLabel(name, label string) { + f.t.Helper() + // Get current data first + cm, err := k8sClient.CoreV1().ConfigMaps(namespace).Get(context.Background(), name, metav1.GetOptions{}) + if err != nil { + f.t.Fatalf("Failed to get ConfigMap %s: %v", name, err) + } + data := cm.Data["url"] + if err := testutil.UpdateConfigMapWithClient(k8sClient, namespace, name, label, data); err != nil { + f.t.Fatalf("Failed to update ConfigMap label %s: %v", name, err) + } +} + +// updateSecret updates a Secret's data. +func (f *testFixture) updateSecret(name, data string) { + f.t.Helper() + if err := testutil.UpdateSecretWithClient(k8sClient, namespace, name, "", data); err != nil { + f.t.Fatalf("Failed to update Secret %s: %v", name, err) + } +} + +// assertDeploymentReloaded asserts that a deployment was reloaded. +func (f *testFixture) assertDeploymentReloaded(name string, testCfg *config.Config) { + f.t.Helper() + if testCfg == nil { + testCfg = cfg + } + updated, err := testutil.WaitForDeploymentReloadedAnnotation(k8sClient, namespace, name, testCfg, waitTimeout) + if err != nil { + f.t.Fatalf("Error waiting for deployment %s update: %v", name, err) + } + if !updated { + f.t.Errorf("Deployment %s was not updated after resource change", name) + } +} + +// assertDeploymentNotReloaded asserts that a deployment was NOT reloaded. +func (f *testFixture) assertDeploymentNotReloaded(name string, testCfg *config.Config) { + f.t.Helper() + if testCfg == nil { + testCfg = cfg + } + time.Sleep(negativeTestTimeout) + updated, _ := testutil.WaitForDeploymentReloadedAnnotation(k8sClient, namespace, name, testCfg, negativeTestTimeout) + if updated { + f.t.Errorf("Deployment %s should not have been updated", name) + } +} + +// assertDaemonSetReloaded asserts that a daemonset was reloaded. +func (f *testFixture) assertDaemonSetReloaded(name string) { + f.t.Helper() + updated, err := testutil.WaitForDaemonSetReloadedAnnotation(k8sClient, namespace, name, cfg, waitTimeout) + if err != nil { + f.t.Fatalf("Error waiting for daemonset %s update: %v", name, err) + } + if !updated { + f.t.Errorf("DaemonSet %s was not updated after resource change", name) + } +} + +// assertStatefulSetReloaded asserts that a statefulset was reloaded. +func (f *testFixture) assertStatefulSetReloaded(name string) { + f.t.Helper() + updated, err := testutil.WaitForStatefulSetReloadedAnnotation(k8sClient, namespace, name, cfg, waitTimeout) + if err != nil { + f.t.Fatalf("Error waiting for statefulset %s update: %v", name, err) + } + if !updated { + f.t.Errorf("StatefulSet %s was not updated after resource change", name) + } +} + +// cleanup removes all created resources. +func (f *testFixture) cleanup() { + for _, w := range f.workloads { + switch w.kind { + case "deployment": + _ = testutil.DeleteDeployment(k8sClient, namespace, w.name) + case "daemonset": + _ = testutil.DeleteDaemonSet(k8sClient, namespace, w.name) + case "statefulset": + _ = testutil.DeleteStatefulSet(k8sClient, namespace, w.name) + } + } + for _, name := range f.configMaps { + _ = testutil.DeleteConfigMap(k8sClient, namespace, name) + } + for _, name := range f.secrets { + _ = testutil.DeleteSecret(k8sClient, namespace, name) + } +} + +func TestMain(m *testing.M) { + flag.Parse() + + if testing.Short() { + os.Exit(0) + } + + // Set up zerolog as the controller-runtime logger + zl := zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}). + Level(zerolog.WarnLevel). + With(). + Timestamp(). + Logger() + ctrllog.SetLogger(zerologr.New(&zl)) + + kubeconfig := os.Getenv("KUBECONFIG") + if kubeconfig == "" { + kubeconfig = os.Getenv("HOME") + "/.kube/config" + } + + var err error + restCfg, err = clientcmd.BuildConfigFromFlags("", kubeconfig) + if err != nil { + skipE2ETests = true + os.Exit(0) + } + + k8sClient, err = kubernetes.NewForConfig(restCfg) + if err != nil { + skipE2ETests = true + os.Exit(0) + } + + if _, err = k8sClient.CoreV1().Namespaces().List(context.Background(), metav1.ListOptions{}); err != nil { + skipE2ETests = true + os.Exit(0) + } + + namespace = testNamespacePrefix + testutil.RandSeq(5) + if err := testutil.CreateNamespace(namespace, k8sClient); err != nil { + panic(err) + } + + cfg = config.NewDefault() + cfg.AutoReloadAll = false + + _, cancelManager = startManagerWithConfig(cfg, restCfg) + + code := m.Run() + + if cancelManager != nil { + cancelManager() + time.Sleep(2 * time.Second) + } + + _ = testutil.DeleteNamespace(namespace, k8sClient) + os.Exit(code) +} + +func skipIfNoCluster(t *testing.T) { + if skipE2ETests { + t.Skip("Skipping e2e test: no Kubernetes cluster available") + } +} + +// TestConfigMapUpdate tests that updating a ConfigMap triggers a workload reload. +func TestConfigMapUpdate(t *testing.T) { + f := newFixture(t, "cm-update") + defer f.cleanup() + + f.createConfigMap(f.name, "initial-data") + f.createDeployment(f.name, true, map[string]string{ + cfg.Annotations.ConfigmapReload: f.name, + }) + f.waitForReady() + + f.updateConfigMap(f.name, "updated-data") + f.assertDeploymentReloaded(f.name, nil) +} + +// TestSecretUpdate tests that updating a Secret triggers a workload reload. +func TestSecretUpdate(t *testing.T) { + f := newFixture(t, "secret-update") + defer f.cleanup() + + f.createSecret(f.name, "initial-secret") + f.createDeployment(f.name, false, map[string]string{ + cfg.Annotations.SecretReload: f.name, + }) + f.waitForReady() + + f.updateSecret(f.name, "updated-secret") + f.assertDeploymentReloaded(f.name, nil) +} + +// TestAutoReloadAll tests the auto-reload-all feature. +func TestAutoReloadAll(t *testing.T) { + f := newFixture(t, "auto-reload") + defer f.cleanup() + + f.createConfigMap(f.name, "initial-data") + f.createDeployment(f.name, true, map[string]string{ + cfg.Annotations.Auto: "true", + }) + f.waitForReady() + + f.updateConfigMap(f.name, "updated-data") + f.assertDeploymentReloaded(f.name, nil) +} + +// TestDaemonSetReload tests that DaemonSets are reloaded when ConfigMaps change. +func TestDaemonSetReload(t *testing.T) { + f := newFixture(t, "ds-reload") + defer f.cleanup() + + f.createConfigMap(f.name, "initial-data") + f.createDaemonSet(f.name, true, map[string]string{ + cfg.Annotations.ConfigmapReload: f.name, + }) + f.waitForReady() + + f.updateConfigMap(f.name, "updated-data") + f.assertDaemonSetReloaded(f.name) +} + +// TestStatefulSetReload tests that StatefulSets are reloaded when Secrets change. +func TestStatefulSetReload(t *testing.T) { + f := newFixture(t, "sts-reload") + defer f.cleanup() + + f.createSecret(f.name, "initial-secret") + f.createStatefulSet(f.name, false, map[string]string{ + cfg.Annotations.SecretReload: f.name, + }) + f.waitForReady() + + f.updateSecret(f.name, "updated-secret") + f.assertStatefulSetReloaded(f.name) +} + +// TestLabelOnlyChange tests that label-only changes don't trigger reloads. +func TestLabelOnlyChange(t *testing.T) { + f := newFixture(t, "label-only") + defer f.cleanup() + + f.createConfigMap(f.name, "initial-data") + f.createDeployment(f.name, true, map[string]string{ + cfg.Annotations.ConfigmapReload: f.name, + }) + f.waitForReady() + + f.updateConfigMapLabel(f.name, "new-label") + f.assertDeploymentNotReloaded(f.name, nil) +} + +// TestMultipleConfigMaps tests watching multiple ConfigMaps in a single annotation. +func TestMultipleConfigMaps(t *testing.T) { + f := newFixture(t, "multi-cm") + defer f.cleanup() + + cm1 := f.name + "-a" + cm2 := f.name + "-b" + + f.createConfigMap(cm1, "data-a") + f.createConfigMap(cm2, "data-b") + f.createDeployment(f.name, true, map[string]string{ + cfg.Annotations.ConfigmapReload: cm1 + "," + cm2, + }) + f.waitForReady() + + f.updateConfigMap(cm1, "updated-data-a") + f.assertDeploymentReloaded(f.name, nil) +} + +// TestAutoAnnotationDisabled tests that auto: "false" disables auto-reload. +func TestAutoAnnotationDisabled(t *testing.T) { + f := newFixture(t, "auto-disabled") + defer f.cleanup() + + testCfg := config.NewDefault() + testCfg.AutoReloadAll = true + + f.createConfigMap(f.name, "initial-data") + f.createDeployment(f.name, true, map[string]string{ + testCfg.Annotations.Auto: "false", + }) + f.waitForReady() + + f.updateConfigMap(f.name, "updated-data") + f.assertDeploymentNotReloaded(f.name, testCfg) +} + +// TestAutoWithExplicitConfigMapAnnotation tests that a deployment with auto=true +// also reloads when an explicitly annotated (non-referenced) ConfigMap changes. +func TestAutoWithExplicitConfigMapAnnotation(t *testing.T) { + f := newFixture(t, "auto-explicit-cm") + defer f.cleanup() + + referencedCM := f.name + "-ref" + explicitCM := f.name + "-explicit" + + f.createConfigMap(referencedCM, "referenced-data") + f.createConfigMap(explicitCM, "explicit-data") + f.createDeployment(referencedCM, true, map[string]string{ + cfg.Annotations.Auto: "true", + cfg.Annotations.ConfigmapReload: explicitCM, + }) + f.waitForReady() + + // Update the EXPLICIT ConfigMap (not the referenced one) + f.updateConfigMap(explicitCM, "updated-explicit-data") + f.assertDeploymentReloaded(referencedCM, nil) +} + +// TestAutoWithExplicitSecretAnnotation tests that a deployment with auto=true +// also reloads when an explicitly annotated (non-referenced) Secret changes. +func TestAutoWithExplicitSecretAnnotation(t *testing.T) { + f := newFixture(t, "auto-explicit-secret") + defer f.cleanup() + + referencedSecret := f.name + "-ref" + explicitSecret := f.name + "-explicit" + + f.createSecret(referencedSecret, "referenced-secret") + f.createSecret(explicitSecret, "explicit-secret") + f.createDeployment(referencedSecret, false, map[string]string{ + cfg.Annotations.Auto: "true", + cfg.Annotations.SecretReload: explicitSecret, + }) + f.waitForReady() + + // Update the EXPLICIT Secret (not the referenced one) + f.updateSecret(explicitSecret, "updated-explicit-secret") + f.assertDeploymentReloaded(referencedSecret, nil) +} + +// TestAutoWithBothExplicitAndReferencedChange tests that auto + explicit annotations +// work correctly when the referenced resource changes. +func TestAutoWithBothExplicitAndReferencedChange(t *testing.T) { + f := newFixture(t, "auto-both") + defer f.cleanup() + + referencedCM := f.name + "-ref" + explicitCM := f.name + "-explicit" + + f.createConfigMap(referencedCM, "referenced-data") + f.createConfigMap(explicitCM, "explicit-data") + f.createDeployment(referencedCM, true, map[string]string{ + cfg.Annotations.Auto: "true", + cfg.Annotations.ConfigmapReload: explicitCM, + }) + f.waitForReady() + + // Update the REFERENCED ConfigMap - should trigger reload via auto + f.updateConfigMap(referencedCM, "updated-referenced-data") + f.assertDeploymentReloaded(referencedCM, nil) +} + +// startManagerWithConfig creates and starts a controller-runtime manager for e2e testing. +func startManagerWithConfig(cfg *config.Config, restConfig *rest.Config) (manager.Manager, context.CancelFunc) { + collectors := metrics.NewCollectors() + mgr, err := controller.NewManagerWithRestConfig( + controller.ManagerOptions{ + Config: cfg, + Log: ctrl.Log.WithName("test-manager"), + Collectors: &collectors, + }, restConfig, + ) + if err != nil { + log.Fatalf("Failed to create manager: %v", err) + } + + if err := controller.SetupReconcilers(mgr, cfg, ctrl.Log.WithName("test-reconcilers"), &collectors); err != nil { + log.Fatalf("Failed to setup reconcilers: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + + go func() { + if err := controller.RunManager(ctx, mgr, ctrl.Log.WithName("test-runner")); err != nil { + log.Printf("Manager exited: %v", err) + } + }() + + time.Sleep(3 * time.Second) + return mgr, cancel +}