mirror of
https://github.com/stakater/Reloader.git
synced 2026-08-23 22:16:45 +00:00
refactor: expose decision engine in public pkg
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user