refactor: expose decision engine in public pkg

This commit is contained in:
Safwan
2026-07-21 18:21:02 +05:00
parent 6037ca4b93
commit 0098e6aef8
49 changed files with 94 additions and 79 deletions
+197
View File
@@ -0,0 +1,197 @@
// Package config provides configuration management for Reloader.
package config
import (
"strings"
"time"
"k8s.io/apimachinery/pkg/labels"
)
// ReloadStrategy defines how Reloader triggers workload restarts.
type ReloadStrategy string
const (
ReloadStrategyEnvVars ReloadStrategy = "env-vars"
ReloadStrategyAnnotations ReloadStrategy = "annotations"
)
// ArgoRolloutStrategy defines the strategy for Argo Rollout updates.
type ArgoRolloutStrategy string
const (
ArgoRolloutStrategyRestart ArgoRolloutStrategy = "restart"
ArgoRolloutStrategyRollout ArgoRolloutStrategy = "rollout"
)
// Config holds all configuration for Reloader.
type Config struct {
Annotations AnnotationConfig `json:"annotations"`
AutoReloadAll bool `json:"autoReloadAll"`
ReloadStrategy ReloadStrategy `json:"reloadStrategy"`
ArgoRolloutsEnabled bool `json:"argoRolloutsEnabled"`
ArgoRolloutStrategy ArgoRolloutStrategy `json:"argoRolloutStrategy"`
DeploymentConfigEnabled bool `json:"deploymentConfigEnabled"`
CSIIntegrationEnabled bool `json:"csiIntegrationEnabled"`
ReloadOnCreate bool `json:"reloadOnCreate"`
ReloadOnDelete bool `json:"reloadOnDelete"`
SyncAfterRestart bool `json:"syncAfterRestart"`
EnableHA bool `json:"enableHA"`
WebhookURL string `json:"webhookUrl,omitempty"`
IgnoredResources []string `json:"ignoredResources,omitempty"`
IgnoredWorkloads []string `json:"ignoredWorkloads,omitempty"`
IgnoredNamespaces []string `json:"ignoredNamespaces,omitempty"`
NamespaceSelectors []labels.Selector `json:"-"`
ResourceSelectors []labels.Selector `json:"-"`
NamespaceSelectorStrings []string `json:"namespaceSelectors,omitempty"`
ResourceSelectorStrings []string `json:"resourceSelectors,omitempty"`
LogFormat string `json:"logFormat,omitempty"`
LogLevel string `json:"logLevel"`
MetricsAddr string `json:"metricsAddr"`
HealthAddr string `json:"healthAddr"`
EnablePProf bool `json:"enablePProf"`
PProfAddr string `json:"pprofAddr,omitempty"`
Alerting AlertingConfig `json:"alerting"`
LeaderElection LeaderElectionConfig `json:"leaderElection"`
WatchedNamespace string `json:"watchedNamespace,omitempty"`
SyncPeriod time.Duration `json:"syncPeriod"`
}
// AnnotationConfig holds customizable annotation keys.
type AnnotationConfig struct {
Prefix string `json:"prefix"`
Auto string `json:"auto"`
ConfigmapAuto string `json:"configmapAuto"`
SecretAuto string `json:"secretAuto"`
ConfigmapReload string `json:"configmapReload"`
SecretReload string `json:"secretReload"`
ConfigmapExclude string `json:"configmapExclude"`
SecretExclude string `json:"secretExclude"`
SecretProviderClassAuto string `json:"secretProviderClassAuto"`
SecretProviderClassReload string `json:"secretProviderClassReload"`
SecretProviderClassExclude string `json:"secretProviderClassExclude"`
Ignore string `json:"ignore"`
Search string `json:"search"`
Match string `json:"match"`
RolloutStrategy string `json:"rolloutStrategy"`
PausePeriod string `json:"pausePeriod"`
PausedAt string `json:"pausedAt"`
LastReloadedFrom string `json:"lastReloadedFrom"`
}
// AlertingConfig holds configuration for alerting integrations.
type AlertingConfig struct {
Enabled bool `json:"enabled"`
WebhookURL string `json:"webhookUrl,omitempty"`
Sink string `json:"sink,omitempty"`
Proxy string `json:"proxy,omitempty"`
Additional string `json:"additional,omitempty"`
Structured bool `json:"structured,omitempty"` // For raw sink: send structured JSON instead of plain text
}
// LeaderElectionConfig holds configuration for leader election.
type LeaderElectionConfig struct {
LockName string `json:"lockName"`
Namespace string `json:"namespace,omitempty"`
Identity string `json:"identity,omitempty"`
LeaseDuration time.Duration `json:"leaseDuration"`
RenewDeadline time.Duration `json:"renewDeadline"`
RetryPeriod time.Duration `json:"retryPeriod"`
ReleaseOnCancel bool `json:"releaseOnCancel"`
}
// NewDefault creates a Config with default values.
func NewDefault() *Config {
return &Config{
Annotations: DefaultAnnotations(),
AutoReloadAll: false,
ReloadStrategy: ReloadStrategyEnvVars,
ArgoRolloutsEnabled: false,
ArgoRolloutStrategy: ArgoRolloutStrategyRollout,
DeploymentConfigEnabled: false,
CSIIntegrationEnabled: false,
ReloadOnCreate: false,
ReloadOnDelete: false,
SyncAfterRestart: false,
EnableHA: false,
WebhookURL: "",
IgnoredResources: []string{},
IgnoredWorkloads: []string{},
IgnoredNamespaces: []string{},
NamespaceSelectors: []labels.Selector{},
ResourceSelectors: []labels.Selector{},
LogFormat: "",
LogLevel: "info",
MetricsAddr: ":9090",
HealthAddr: ":8080",
EnablePProf: false,
PProfAddr: ":6060",
Alerting: AlertingConfig{},
LeaderElection: LeaderElectionConfig{
LockName: "reloader-leader-election",
LeaseDuration: 15 * time.Second,
RenewDeadline: 10 * time.Second,
RetryPeriod: 2 * time.Second,
ReleaseOnCancel: true,
},
WatchedNamespace: "",
SyncPeriod: 0,
}
}
// DefaultAnnotations returns the default annotation configuration.
func DefaultAnnotations() AnnotationConfig {
return AnnotationConfig{
Prefix: "reloader.stakater.com",
Auto: "reloader.stakater.com/auto",
ConfigmapAuto: "configmap.reloader.stakater.com/auto",
SecretAuto: "secret.reloader.stakater.com/auto",
ConfigmapReload: "configmap.reloader.stakater.com/reload",
SecretReload: "secret.reloader.stakater.com/reload",
ConfigmapExclude: "configmaps.exclude.reloader.stakater.com/reload",
SecretExclude: "secrets.exclude.reloader.stakater.com/reload",
SecretProviderClassAuto: "secretproviderclass.reloader.stakater.com/auto",
SecretProviderClassReload: "secretproviderclass.reloader.stakater.com/reload",
SecretProviderClassExclude: "secretproviderclasses.exclude.reloader.stakater.com/reload",
Ignore: "reloader.stakater.com/ignore",
Search: "reloader.stakater.com/search",
Match: "reloader.stakater.com/match",
RolloutStrategy: "reloader.stakater.com/rollout-strategy",
PausePeriod: "deployment.reloader.stakater.com/pause-period",
PausedAt: "deployment.reloader.stakater.com/paused-at",
LastReloadedFrom: "reloader.stakater.com/last-reloaded-from",
}
}
// IsResourceIgnored checks if a resource name should be ignored (case-insensitive).
func (c *Config) IsResourceIgnored(name string) bool {
for _, ignored := range c.IgnoredResources {
if strings.EqualFold(ignored, name) {
return true
}
}
return false
}
// IsWorkloadIgnored checks if a workload type should be ignored (case-insensitive).
func (c *Config) IsWorkloadIgnored(workloadType string) bool {
for _, ignored := range c.IgnoredWorkloads {
if strings.EqualFold(ignored, workloadType) {
return true
}
}
return false
}
// IsNamespaceIgnored checks if a namespace should be ignored.
func (c *Config) IsNamespaceIgnored(namespace string) bool {
for _, ignored := range c.IgnoredNamespaces {
if ignored == namespace {
return true
}
}
return false
}
+220
View File
@@ -0,0 +1,220 @@
package config
import (
"testing"
"time"
)
func TestNewDefault(t *testing.T) {
cfg := NewDefault()
if cfg == nil {
t.Fatal("NewDefault() returned nil")
}
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 != ":8080" {
t.Errorf("HealthAddr = %q, want %q", cfg.HealthAddr, ":8080")
}
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 TestDefaultAnnotationsSecretProviderClass(t *testing.T) {
a := DefaultAnnotations()
if a.SecretProviderClassAuto != "secretproviderclass.reloader.stakater.com/auto" {
t.Fatalf("SecretProviderClassAuto = %q", a.SecretProviderClassAuto)
}
if a.SecretProviderClassReload != "secretproviderclass.reloader.stakater.com/reload" {
t.Fatalf("SecretProviderClassReload = %q", a.SecretProviderClassReload)
}
if a.SecretProviderClassExclude != "secretproviderclasses.exclude.reloader.stakater.com/reload" {
t.Fatalf("SecretProviderClassExclude = %q", a.SecretProviderClassExclude)
}
}
func TestNewDefaultCSIDisabled(t *testing.T) {
if NewDefault().CSIIntegrationEnabled {
t.Fatal("CSIIntegrationEnabled should default to false")
}
}
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)
}
},
)
}
}
+160
View File
@@ -0,0 +1,160 @@
package config
import (
"fmt"
"strings"
"k8s.io/apimachinery/pkg/labels"
"github.com/stakater/Reloader/internal/pkg/workload"
)
// ValidationError represents a configuration validation error.
type ValidationError struct {
Field string
Message string
}
func (e ValidationError) Error() string {
return fmt.Sprintf("config.%s: %s", e.Field, e.Message)
}
// ValidationErrors is a collection of validation errors.
type ValidationErrors []ValidationError
func (e ValidationErrors) Error() string {
if len(e) == 0 {
return ""
}
if len(e) == 1 {
return e[0].Error()
}
var b strings.Builder
b.WriteString("multiple configuration errors:\n")
for _, err := range e {
b.WriteString(" - ")
b.WriteString(err.Error())
b.WriteString("\n")
}
return b.String()
}
// Validate checks the configuration for errors and normalizes values.
func (c *Config) Validate() error {
var errs ValidationErrors
switch c.ReloadStrategy {
case ReloadStrategyEnvVars, ReloadStrategyAnnotations:
// valid
case "":
c.ReloadStrategy = ReloadStrategyEnvVars
default:
errs = append(
errs, ValidationError{
Field: "ReloadStrategy",
Message: fmt.Sprintf("invalid value %q, must be %q or %q", c.ReloadStrategy, ReloadStrategyEnvVars, ReloadStrategyAnnotations),
},
)
}
switch c.ArgoRolloutStrategy {
case ArgoRolloutStrategyRestart, ArgoRolloutStrategyRollout:
// valid
case "":
c.ArgoRolloutStrategy = ArgoRolloutStrategyRollout
default:
errs = append(
errs, ValidationError{
Field: "ArgoRolloutStrategy",
Message: fmt.Sprintf(
"invalid value %q, must be %q or %q", c.ArgoRolloutStrategy, ArgoRolloutStrategyRestart, ArgoRolloutStrategyRollout,
),
},
)
}
switch strings.ToLower(c.LogLevel) {
case "trace", "debug", "info", "warn", "warning", "error", "fatal", "panic", "":
// valid
default:
errs = append(
errs, ValidationError{
Field: "LogLevel",
Message: fmt.Sprintf("invalid log level %q", c.LogLevel),
},
)
}
switch strings.ToLower(c.LogFormat) {
case "json", "":
// valid
default:
errs = append(
errs, ValidationError{
Field: "LogFormat",
Message: fmt.Sprintf("invalid log format %q, must be \"json\" or empty", c.LogFormat),
},
)
}
c.IgnoredResources = normalizeToLower(c.IgnoredResources)
// Normalize ignored workloads to canonical Kind values (e.g., "cronjobs" -> "CronJob")
c.IgnoredWorkloads = normalizeToLower(c.IgnoredWorkloads)
normalizedWorkloads := make([]string, 0, len(c.IgnoredWorkloads))
for _, w := range c.IgnoredWorkloads {
kind, err := workload.KindFromString(w)
if err != nil {
errs = append(
errs, ValidationError{
Field: "IgnoredWorkloads",
Message: fmt.Sprintf("unknown workload type %q", w),
},
)
} else {
normalizedWorkloads = append(normalizedWorkloads, string(kind))
}
}
c.IgnoredWorkloads = normalizedWorkloads
if len(errs) > 0 {
return errs
}
return nil
}
// normalizeToLower converts all strings in the slice to lowercase and removes empty strings.
func normalizeToLower(items []string) []string {
if len(items) == 0 {
return items
}
result := make([]string, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(strings.ToLower(item))
if item != "" {
result = append(result, item)
}
}
return result
}
// ParseSelectors parses a slice of selector strings into label selectors.
func ParseSelectors(selectorStrings []string) ([]labels.Selector, error) {
if len(selectorStrings) == 0 {
return nil, nil
}
selectors := make([]labels.Selector, 0, len(selectorStrings))
for _, s := range selectorStrings {
s = strings.TrimSpace(s)
if s == "" {
continue
}
selector, err := labels.Parse(s)
if err != nil {
return nil, fmt.Errorf("invalid selector %q: %w", s, err)
}
selectors = append(selectors, selector)
}
return selectors, nil
}
+339
View File
@@ -0,0 +1,339 @@
package config
import (
"errors"
"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)
}
// Should be normalized to canonical Kind values (e.g., "CronJob" not "cronjobs")
expected := []string{"Job", "CronJob"}
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_InvalidIgnoredWorkload(t *testing.T) {
cfg := NewDefault()
cfg.IgnoredWorkloads = []string{"deployment", "invalidtype"}
err := cfg.Validate()
if err == nil {
t.Fatal("Validate() should return error for invalid workload type")
}
if !strings.Contains(err.Error(), "invalidtype") {
t.Errorf("Error should mention invalid workload type, got: %v", err)
}
}
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")
}
var errs ValidationErrors
ok := errors.As(err, &errs)
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 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])
}
}
},
)
}
}