feat: Add reconciler test cases and config test cases

This commit is contained in:
TheiLLeniumStudios
2025-12-28 08:47:55 +01:00
parent f48c5ac1b3
commit 9d588cadb2
5 changed files with 2748 additions and 0 deletions
+237
View File
@@ -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")
}
}
+330
View File
@@ -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])
}
}
})
}
}
+320
View File
@@ -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])
}
}
})
}
}
@@ -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")
}
}
File diff suppressed because it is too large Load Diff