diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go new file mode 100644 index 00000000..d9f740a7 --- /dev/null +++ b/internal/pkg/config/config_test.go @@ -0,0 +1,237 @@ +package config + +import ( + "testing" + "time" +) + +func TestNewDefault(t *testing.T) { + cfg := NewDefault() + + if cfg == nil { + t.Fatal("NewDefault() returned nil") + } + + // Test default values + if cfg.ReloadStrategy != ReloadStrategyEnvVars { + t.Errorf("ReloadStrategy = %v, want %v", cfg.ReloadStrategy, ReloadStrategyEnvVars) + } + + if cfg.ArgoRolloutStrategy != ArgoRolloutStrategyRollout { + t.Errorf("ArgoRolloutStrategy = %v, want %v", cfg.ArgoRolloutStrategy, ArgoRolloutStrategyRollout) + } + + if cfg.AutoReloadAll { + t.Error("AutoReloadAll should be false by default") + } + + if cfg.ArgoRolloutsEnabled { + t.Error("ArgoRolloutsEnabled should be false by default") + } + + if cfg.ReloadOnCreate { + t.Error("ReloadOnCreate should be false by default") + } + + if cfg.ReloadOnDelete { + t.Error("ReloadOnDelete should be false by default") + } + + if cfg.EnableHA { + t.Error("EnableHA should be false by default") + } + + if cfg.LogLevel != "info" { + t.Errorf("LogLevel = %q, want %q", cfg.LogLevel, "info") + } + + if cfg.MetricsAddr != ":9090" { + t.Errorf("MetricsAddr = %q, want %q", cfg.MetricsAddr, ":9090") + } + + if cfg.HealthAddr != ":8081" { + t.Errorf("HealthAddr = %q, want %q", cfg.HealthAddr, ":8081") + } + + if cfg.PProfAddr != ":6060" { + t.Errorf("PProfAddr = %q, want %q", cfg.PProfAddr, ":6060") + } +} + +func TestDefaultAnnotations(t *testing.T) { + ann := DefaultAnnotations() + + tests := []struct { + name string + got string + want string + }{ + {"Prefix", ann.Prefix, "reloader.stakater.com"}, + {"Auto", ann.Auto, "reloader.stakater.com/auto"}, + {"ConfigmapAuto", ann.ConfigmapAuto, "configmap.reloader.stakater.com/auto"}, + {"SecretAuto", ann.SecretAuto, "secret.reloader.stakater.com/auto"}, + {"ConfigmapReload", ann.ConfigmapReload, "configmap.reloader.stakater.com/reload"}, + {"SecretReload", ann.SecretReload, "secret.reloader.stakater.com/reload"}, + {"ConfigmapExclude", ann.ConfigmapExclude, "configmaps.exclude.reloader.stakater.com/reload"}, + {"SecretExclude", ann.SecretExclude, "secrets.exclude.reloader.stakater.com/reload"}, + {"Ignore", ann.Ignore, "reloader.stakater.com/ignore"}, + {"Search", ann.Search, "reloader.stakater.com/search"}, + {"Match", ann.Match, "reloader.stakater.com/match"}, + {"RolloutStrategy", ann.RolloutStrategy, "reloader.stakater.com/rollout-strategy"}, + {"PausePeriod", ann.PausePeriod, "deployment.reloader.stakater.com/pause-period"}, + {"PausedAt", ann.PausedAt, "deployment.reloader.stakater.com/paused-at"}, + {"LastReloadedFrom", ann.LastReloadedFrom, "reloader.stakater.com/last-reloaded-from"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.want { + t.Errorf("%s = %q, want %q", tt.name, tt.got, tt.want) + } + }) + } +} + +func TestDefaultLeaderElection(t *testing.T) { + cfg := NewDefault() + + if cfg.LeaderElection.LockName != "reloader-leader-election" { + t.Errorf("LockName = %q, want %q", cfg.LeaderElection.LockName, "reloader-leader-election") + } + + if cfg.LeaderElection.LeaseDuration != 15*time.Second { + t.Errorf("LeaseDuration = %v, want %v", cfg.LeaderElection.LeaseDuration, 15*time.Second) + } + + if cfg.LeaderElection.RenewDeadline != 10*time.Second { + t.Errorf("RenewDeadline = %v, want %v", cfg.LeaderElection.RenewDeadline, 10*time.Second) + } + + if cfg.LeaderElection.RetryPeriod != 2*time.Second { + t.Errorf("RetryPeriod = %v, want %v", cfg.LeaderElection.RetryPeriod, 2*time.Second) + } + + if !cfg.LeaderElection.ReleaseOnCancel { + t.Error("ReleaseOnCancel should be true by default") + } +} + +func TestConfig_IsResourceIgnored(t *testing.T) { + cfg := NewDefault() + cfg.IgnoredResources = []string{"configmaps", "secrets"} + + tests := []struct { + name string + resource string + want bool + }{ + {"exact match lowercase", "configmaps", true}, + {"exact match uppercase", "CONFIGMAPS", true}, + {"exact match mixed case", "ConfigMaps", true}, + {"not ignored", "deployments", false}, + {"partial match (not ignored)", "config", false}, + {"empty string", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cfg.IsResourceIgnored(tt.resource) + if got != tt.want { + t.Errorf("IsResourceIgnored(%q) = %v, want %v", tt.resource, got, tt.want) + } + }) + } +} + +func TestConfig_IsWorkloadIgnored(t *testing.T) { + cfg := NewDefault() + cfg.IgnoredWorkloads = []string{"jobs", "cronjobs"} + + tests := []struct { + name string + workload string + want bool + }{ + {"exact match", "jobs", true}, + {"case insensitive", "JOBS", true}, + {"not ignored", "deployments", false}, + {"empty string", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cfg.IsWorkloadIgnored(tt.workload) + if got != tt.want { + t.Errorf("IsWorkloadIgnored(%q) = %v, want %v", tt.workload, got, tt.want) + } + }) + } +} + +func TestConfig_IsNamespaceIgnored(t *testing.T) { + cfg := NewDefault() + cfg.IgnoredNamespaces = []string{"kube-system", "kube-public"} + + tests := []struct { + name string + namespace string + want bool + }{ + {"exact match", "kube-system", true}, + {"case sensitive no match", "Kube-System", false}, + {"not ignored", "default", false}, + {"empty string", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cfg.IsNamespaceIgnored(tt.namespace) + if got != tt.want { + t.Errorf("IsNamespaceIgnored(%q) = %v, want %v", tt.namespace, got, tt.want) + } + }) + } +} + +func TestEqualFold(t *testing.T) { + tests := []struct { + s, t string + want bool + }{ + {"abc", "abc", true}, + {"ABC", "abc", true}, + {"abc", "ABC", true}, + {"aBc", "AbC", true}, + {"abc", "abcd", false}, + {"", "", true}, + {"a", "", false}, + {"", "a", false}, + } + + for _, tt := range tests { + t.Run(tt.s+"_"+tt.t, func(t *testing.T) { + got := equalFold(tt.s, tt.t) + if got != tt.want { + t.Errorf("equalFold(%q, %q) = %v, want %v", tt.s, tt.t, got, tt.want) + } + }) + } +} + +func TestReloadStrategy_String(t *testing.T) { + if string(ReloadStrategyEnvVars) != "env-vars" { + t.Errorf("ReloadStrategyEnvVars = %q, want %q", ReloadStrategyEnvVars, "env-vars") + } + if string(ReloadStrategyAnnotations) != "annotations" { + t.Errorf("ReloadStrategyAnnotations = %q, want %q", ReloadStrategyAnnotations, "annotations") + } +} + +func TestArgoRolloutStrategy_String(t *testing.T) { + if string(ArgoRolloutStrategyRestart) != "restart" { + t.Errorf("ArgoRolloutStrategyRestart = %q, want %q", ArgoRolloutStrategyRestart, "restart") + } + if string(ArgoRolloutStrategyRollout) != "rollout" { + t.Errorf("ArgoRolloutStrategyRollout = %q, want %q", ArgoRolloutStrategyRollout, "rollout") + } +} diff --git a/internal/pkg/config/flags_test.go b/internal/pkg/config/flags_test.go new file mode 100644 index 00000000..4ddcbaeb --- /dev/null +++ b/internal/pkg/config/flags_test.go @@ -0,0 +1,330 @@ +package config + +import ( + "testing" + + "github.com/spf13/pflag" +) + +func TestBindFlags(t *testing.T) { + cfg := NewDefault() + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + + BindFlags(fs, cfg) + + // Verify flags are registered + expectedFlags := []string{ + "auto-reload-all", + "reload-strategy", + "is-Argo-Rollouts", + "reload-on-create", + "reload-on-delete", + "sync-after-restart", + "enable-ha", + "leader-election-id", + "leader-election-namespace", + "leader-election-lease-duration", + "leader-election-renew-deadline", + "leader-election-retry-period", + "leader-election-release-on-cancel", + "webhook-url", + "resources-to-ignore", + "ignored-workload-types", + "namespaces-to-ignore", + "namespace-selector", + "resource-label-selector", + "log-format", + "log-level", + "metrics-addr", + "health-addr", + "enable-pprof", + "pprof-addr", + "auto-annotation", + "configmap-auto-annotation", + "secret-auto-annotation", + "configmap-annotation", + "secret-annotation", + "auto-search-annotation", + "search-match-annotation", + "pause-deployment-annotation", + "pause-deployment-time-annotation", + "watch-namespace", + } + + for _, flagName := range expectedFlags { + if fs.Lookup(flagName) == nil { + t.Errorf("Expected flag %q to be registered", flagName) + } + } +} + +func TestBindFlags_DefaultValues(t *testing.T) { + cfg := NewDefault() + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + + 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) + } + + if cfg.LogLevel != "info" { + t.Errorf("LogLevel = %q, want %q", cfg.LogLevel, "info") + } +} + +func TestBindFlags_CustomValues(t *testing.T) { + cfg := NewDefault() + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + + BindFlags(fs, cfg) + + args := []string{ + "--auto-reload-all=true", + "--reload-strategy=annotations", + "--log-level=debug", + "--log-format=json", + "--webhook-url=https://example.com/hook", + "--enable-ha=true", + "--enable-pprof=true", + } + + if err := fs.Parse(args); err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if !cfg.AutoReloadAll { + t.Error("AutoReloadAll should be true") + } + + if cfg.ReloadStrategy != ReloadStrategyAnnotations { + t.Errorf("ReloadStrategy = %v, want %v", cfg.ReloadStrategy, ReloadStrategyAnnotations) + } + + if cfg.LogLevel != "debug" { + t.Errorf("LogLevel = %q, want %q", cfg.LogLevel, "debug") + } + + if cfg.LogFormat != "json" { + t.Errorf("LogFormat = %q, want %q", cfg.LogFormat, "json") + } + + if cfg.WebhookURL != "https://example.com/hook" { + t.Errorf("WebhookURL = %q, want %q", cfg.WebhookURL, "https://example.com/hook") + } + + if !cfg.EnableHA { + t.Error("EnableHA should be true") + } + + if !cfg.EnablePProf { + t.Error("EnablePProf should be true") + } +} + +func TestApplyFlags_BooleanStrings(t *testing.T) { + tests := []struct { + name string + args []string + want bool + wantErr bool + }{ + {"true lowercase", []string{"--is-Argo-Rollouts=true"}, true, false}, + {"TRUE uppercase", []string{"--is-Argo-Rollouts=TRUE"}, true, false}, + {"1", []string{"--is-Argo-Rollouts=1"}, true, false}, + {"yes", []string{"--is-Argo-Rollouts=yes"}, true, false}, + {"false", []string{"--is-Argo-Rollouts=false"}, false, false}, + {"no", []string{"--is-Argo-Rollouts=no"}, false, false}, + {"0", []string{"--is-Argo-Rollouts=0"}, false, false}, + {"empty", []string{}, false, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Reset flag values + fv = flagValues{} + + 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) + } + + 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) + } + }) + } +} + +func TestApplyFlags_CommaSeparatedLists(t *testing.T) { + // Reset flag values + fv = flagValues{} + + cfg := NewDefault() + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + BindFlags(fs, cfg) + + args := []string{ + "--resources-to-ignore=configMaps,secrets", + "--ignored-workload-types=jobs,cronjobs", + "--namespaces-to-ignore=kube-system,kube-public", + } + + if err := fs.Parse(args); err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if err := ApplyFlags(cfg); err != nil { + t.Fatalf("ApplyFlags() error = %v", err) + } + + // Check ignored resources + if len(cfg.IgnoredResources) != 2 { + t.Errorf("IgnoredResources length = %d, want 2", len(cfg.IgnoredResources)) + } + if cfg.IgnoredResources[0] != "configMaps" || cfg.IgnoredResources[1] != "secrets" { + t.Errorf("IgnoredResources = %v", cfg.IgnoredResources) + } + + // Check ignored workloads + if len(cfg.IgnoredWorkloads) != 2 { + t.Errorf("IgnoredWorkloads length = %d, want 2", len(cfg.IgnoredWorkloads)) + } + + // Check ignored namespaces + if len(cfg.IgnoredNamespaces) != 2 { + t.Errorf("IgnoredNamespaces length = %d, want 2", len(cfg.IgnoredNamespaces)) + } +} + +func TestApplyFlags_Selectors(t *testing.T) { + // Reset flag values + fv = flagValues{} + + cfg := NewDefault() + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + BindFlags(fs, cfg) + + args := []string{ + "--namespace-selector=env=production,team=platform", + "--resource-label-selector=app=myapp", + } + + if err := fs.Parse(args); err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if err := ApplyFlags(cfg); err != nil { + t.Fatalf("ApplyFlags() error = %v", err) + } + + if len(cfg.NamespaceSelectors) != 2 { + t.Errorf("NamespaceSelectors length = %d, want 2", len(cfg.NamespaceSelectors)) + } + + if len(cfg.ResourceSelectors) != 1 { + 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() + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + BindFlags(fs, cfg) + + args := []string{ + "--namespace-selector=env in (prod,staging", // missing closing paren + } + + if err := fs.Parse(args); err != nil { + t.Fatalf("Parse() error = %v", err) + } + + err := ApplyFlags(cfg) + if err == nil { + t.Error("ApplyFlags() should return error for invalid selector") + } +} + +func TestParseBoolString(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"true", true}, + {"TRUE", true}, + {"True", true}, + {" true ", true}, + {"1", true}, + {"yes", true}, + {"YES", true}, + {"false", false}, + {"FALSE", false}, + {"0", false}, + {"no", false}, + {"", false}, + {"invalid", false}, + } + + 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) + } + }) + } +} + +func TestSplitAndTrim(t *testing.T) { + tests := []struct { + name string + input string + want []string + }{ + {"empty string", "", nil}, + {"single value", "abc", []string{"abc"}}, + {"multiple values", "a,b,c", []string{"a", "b", "c"}}, + {"with spaces", " a , b , c ", []string{"a", "b", "c"}}, + {"empty elements", "a,,b", []string{"a", "b"}}, + {"only commas", ",,,", []string{}}, + } + + 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]) + } + } + }) + } +} diff --git a/internal/pkg/config/validation_test.go b/internal/pkg/config/validation_test.go new file mode 100644 index 00000000..2972333c --- /dev/null +++ b/internal/pkg/config/validation_test.go @@ -0,0 +1,320 @@ +package config + +import ( + "strings" + "testing" +) + +func TestConfig_Validate_ReloadStrategy(t *testing.T) { + tests := []struct { + name string + strategy ReloadStrategy + wantErr bool + wantVal ReloadStrategy + }{ + {"valid env-vars", ReloadStrategyEnvVars, false, ReloadStrategyEnvVars}, + {"valid annotations", ReloadStrategyAnnotations, false, ReloadStrategyAnnotations}, + {"empty defaults to env-vars", "", false, ReloadStrategyEnvVars}, + {"invalid strategy", "invalid", true, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := NewDefault() + cfg.ReloadStrategy = tt.strategy + + err := cfg.Validate() + + if tt.wantErr { + if err == nil { + t.Error("Validate() should return error for invalid strategy") + } + return + } + + if err != nil { + t.Errorf("Validate() error = %v", err) + return + } + + if cfg.ReloadStrategy != tt.wantVal { + t.Errorf("ReloadStrategy = %v, want %v", cfg.ReloadStrategy, tt.wantVal) + } + }) + } +} + +func TestConfig_Validate_ArgoRolloutStrategy(t *testing.T) { + tests := []struct { + name string + strategy ArgoRolloutStrategy + wantErr bool + wantVal ArgoRolloutStrategy + }{ + {"valid restart", ArgoRolloutStrategyRestart, false, ArgoRolloutStrategyRestart}, + {"valid rollout", ArgoRolloutStrategyRollout, false, ArgoRolloutStrategyRollout}, + {"empty defaults to rollout", "", false, ArgoRolloutStrategyRollout}, + {"invalid strategy", "invalid", true, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := NewDefault() + cfg.ArgoRolloutStrategy = tt.strategy + + err := cfg.Validate() + + if tt.wantErr { + if err == nil { + t.Error("Validate() should return error for invalid strategy") + } + return + } + + if err != nil { + t.Errorf("Validate() error = %v", err) + return + } + + if cfg.ArgoRolloutStrategy != tt.wantVal { + t.Errorf("ArgoRolloutStrategy = %v, want %v", cfg.ArgoRolloutStrategy, tt.wantVal) + } + }) + } +} + +func TestConfig_Validate_LogLevel(t *testing.T) { + validLevels := []string{"trace", "debug", "info", "warn", "warning", "error", "fatal", "panic", ""} + for _, level := range validLevels { + t.Run("valid_"+level, func(t *testing.T) { + cfg := NewDefault() + cfg.LogLevel = level + if err := cfg.Validate(); err != nil { + t.Errorf("Validate() error for level %q: %v", level, err) + } + }) + } + + t.Run("invalid level", func(t *testing.T) { + cfg := NewDefault() + cfg.LogLevel = "invalid" + err := cfg.Validate() + if err == nil { + t.Error("Validate() should return error for invalid log level") + } + }) +} + +func TestConfig_Validate_LogFormat(t *testing.T) { + tests := []struct { + name string + format string + wantErr bool + }{ + {"json format", "json", false}, + {"empty format", "", false}, + {"invalid format", "xml", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := NewDefault() + cfg.LogFormat = tt.format + err := cfg.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestConfig_Validate_NormalizesIgnoredResources(t *testing.T) { + cfg := NewDefault() + cfg.IgnoredResources = []string{"ConfigMaps", "SECRETS", " spaces "} + + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } + + expected := []string{"configmaps", "secrets", "spaces"} + if len(cfg.IgnoredResources) != len(expected) { + t.Fatalf("IgnoredResources length = %d, want %d", len(cfg.IgnoredResources), len(expected)) + } + + for i, got := range cfg.IgnoredResources { + if got != expected[i] { + t.Errorf("IgnoredResources[%d] = %q, want %q", i, got, expected[i]) + } + } +} + +func TestConfig_Validate_NormalizesIgnoredWorkloads(t *testing.T) { + cfg := NewDefault() + cfg.IgnoredWorkloads = []string{"Jobs", "CRONJOBS", ""} + + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } + + expected := []string{"jobs", "cronjobs"} + if len(cfg.IgnoredWorkloads) != len(expected) { + t.Fatalf("IgnoredWorkloads length = %d, want %d", len(cfg.IgnoredWorkloads), len(expected)) + } + + for i, got := range cfg.IgnoredWorkloads { + if got != expected[i] { + t.Errorf("IgnoredWorkloads[%d] = %q, want %q", i, got, expected[i]) + } + } +} + +func TestConfig_Validate_MultipleErrors(t *testing.T) { + cfg := NewDefault() + cfg.ReloadStrategy = "invalid" + cfg.ArgoRolloutStrategy = "invalid" + cfg.LogLevel = "invalid" + cfg.LogFormat = "invalid" + + err := cfg.Validate() + if err == nil { + t.Fatal("Validate() should return error for multiple invalid values") + } + + errs, ok := err.(ValidationErrors) + if !ok { + t.Fatalf("Expected ValidationErrors, got %T", err) + } + + if len(errs) != 4 { + t.Errorf("Expected 4 errors, got %d: %v", len(errs), errs) + } +} + +func TestValidationError_Error(t *testing.T) { + err := ValidationError{ + Field: "TestField", + Message: "test message", + } + + expected := "config.TestField: test message" + if err.Error() != expected { + t.Errorf("Error() = %q, want %q", err.Error(), expected) + } +} + +func TestValidationErrors_Error(t *testing.T) { + t.Run("empty", func(t *testing.T) { + var errs ValidationErrors + if errs.Error() != "" { + t.Errorf("Empty errors should return empty string, got %q", errs.Error()) + } + }) + + t.Run("single error", func(t *testing.T) { + errs := ValidationErrors{ + {Field: "Field1", Message: "error1"}, + } + if !strings.Contains(errs.Error(), "Field1") { + t.Errorf("Error() should contain field name, got %q", errs.Error()) + } + }) + + t.Run("multiple errors", func(t *testing.T) { + errs := ValidationErrors{ + {Field: "Field1", Message: "error1"}, + {Field: "Field2", Message: "error2"}, + } + errStr := errs.Error() + if !strings.Contains(errStr, "multiple configuration errors") { + t.Errorf("Error() should mention multiple errors, got %q", errStr) + } + if !strings.Contains(errStr, "Field1") || !strings.Contains(errStr, "Field2") { + t.Errorf("Error() should contain all field names, got %q", errStr) + } + }) +} + +func TestParseSelectors(t *testing.T) { + tests := []struct { + name string + selectors []string + wantLen int + wantErr bool + }{ + {"nil input", nil, 0, false}, + {"empty input", []string{}, 0, false}, + {"single valid selector", []string{"env=production"}, 1, false}, + {"multiple valid selectors", []string{"env=production", "team=platform"}, 2, false}, + {"selector with whitespace", []string{" env=production "}, 1, false}, + {"empty string in list", []string{"env=production", "", "team=platform"}, 2, false}, + {"invalid selector syntax", []string{"env in (prod,staging"}, 0, true}, // missing closing paren + {"set-based selector", []string{"env in (prod,staging)"}, 1, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + selectors, err := ParseSelectors(tt.selectors) + if (err != nil) != tt.wantErr { + t.Errorf("ParseSelectors() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && len(selectors) != tt.wantLen { + t.Errorf("ParseSelectors() returned %d selectors, want %d", len(selectors), tt.wantLen) + } + }) + } +} + +func TestMustParseSelectors(t *testing.T) { + t.Run("valid selectors", func(t *testing.T) { + selectors := MustParseSelectors([]string{"env=production"}) + if len(selectors) != 1 { + t.Errorf("MustParseSelectors() returned %d selectors, want 1", len(selectors)) + } + }) + + t.Run("panics on invalid", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("MustParseSelectors() should panic on invalid selector") + } + }() + MustParseSelectors([]string{"env in (prod,staging"}) // missing closing paren + }) +} + +func TestNormalizeToLower(t *testing.T) { + tests := []struct { + name string + input []string + want []string + }{ + {"nil input", nil, nil}, + {"empty input", []string{}, []string{}}, + {"lowercase", []string{"abc"}, []string{"abc"}}, + {"uppercase", []string{"ABC"}, []string{"abc"}}, + {"mixed case", []string{"AbC"}, []string{"abc"}}, + {"with whitespace", []string{" abc "}, []string{"abc"}}, + {"removes empty", []string{"abc", "", "def"}, []string{"abc", "def"}}, + {"only whitespace", []string{" "}, []string{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeToLower(tt.input) + if tt.want == nil && got != nil { + t.Errorf("normalizeToLower() = %v, want nil", got) + return + } + if len(got) != len(tt.want) { + t.Errorf("normalizeToLower() length = %d, want %d", len(got), len(tt.want)) + return + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("normalizeToLower()[%d] = %q, want %q", i, got[i], tt.want[i]) + } + } + }) + } +} diff --git a/internal/pkg/controller/configmap_reconciler_test.go b/internal/pkg/controller/configmap_reconciler_test.go new file mode 100644 index 00000000..cd4e8d35 --- /dev/null +++ b/internal/pkg/controller/configmap_reconciler_test.go @@ -0,0 +1,844 @@ +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") + } +} + +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") + } +} + +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") + } +} + +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") + } +} + +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") + } +} + +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") + } +} + +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") + } +} + +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") + } +} + +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") + } +} + +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") + } +} + +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"}, + } + + 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") + } +} + +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") + } +} + +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") + } +} + +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") + } +} diff --git a/internal/pkg/controller/secret_reconciler_test.go b/internal/pkg/controller/secret_reconciler_test.go new file mode 100644 index 00000000..155324aa --- /dev/null +++ b/internal/pkg/controller/secret_reconciler_test.go @@ -0,0 +1,1017 @@ +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") + } +} + +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") + } +} + +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") + } +} + +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") + } +} + +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") + } +} + +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") + } +} + +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") + } +} + +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") + } +} + +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") + } +} + +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")}, + } + + 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", + }}, + }, + }, + }, + } + + reconciler := newTestSecretReconciler(t, cfg, secret, statefulset) + + 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") + } +} + +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") + } +} + +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") + } +} + +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") + } +} + +func TestSecretReconciler_TLSSecret(t *testing.T) { + cfg := config.NewDefault() + + 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-----"), + }, + } + + 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") + } +}