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
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"time"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/pkg/config"
)
// AlertMessage contains the details of a reload event to be sent as an alert.
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"testing"
"time"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/pkg/config"
)
// testServer creates a test HTTP server that captures the request body.
-197
View File
@@ -1,197 +0,0 @@
// 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
@@ -1,220 +0,0 @@
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)
}
},
)
}
}
@@ -1,4 +1,4 @@
package config
package flags
import (
"fmt"
@@ -8,6 +8,8 @@ import (
"github.com/spf13/pflag"
"github.com/spf13/viper"
"k8s.io/apimachinery/pkg/labels"
"github.com/stakater/Reloader/pkg/config"
)
// v is the viper instance for configuration.
@@ -22,7 +24,7 @@ func init() {
// BindFlags binds configuration flags to the provided flag set.
// Call this before parsing flags, then call ApplyFlags after parsing.
func BindFlags(fs *pflag.FlagSet, cfg *Config) {
func BindFlags(fs *pflag.FlagSet, cfg *config.Config) {
// Auto reload
fs.Bool(
"auto-reload-all", cfg.AutoReloadAll,
@@ -265,7 +267,7 @@ func BindFlags(fs *pflag.FlagSet, cfg *Config) {
// ApplyFlags applies flag values from viper to the config struct.
// Call this after parsing flags.
func ApplyFlags(cfg *Config) error {
func ApplyFlags(cfg *config.Config) error {
// Boolean flags
cfg.AutoReloadAll = v.GetBool("auto-reload-all")
cfg.SyncAfterRestart = v.GetBool("sync-after-restart")
@@ -287,7 +289,7 @@ func ApplyFlags(cfg *Config) error {
}
// String flags
cfg.ReloadStrategy = ReloadStrategy(v.GetString("reload-strategy"))
cfg.ReloadStrategy = config.ReloadStrategy(v.GetString("reload-strategy"))
cfg.WebhookURL = v.GetString("webhook-url")
cfg.LogFormat = v.GetString("log-format")
cfg.LogLevel = v.GetString("log-level")
@@ -1,4 +1,4 @@
package config
package flags
import (
"strings"
@@ -6,6 +6,8 @@ import (
"github.com/spf13/pflag"
"github.com/spf13/viper"
"github.com/stakater/Reloader/pkg/config"
)
// resetViper resets the viper instance for testing.
@@ -17,7 +19,7 @@ func resetViper() {
func TestBindFlags(t *testing.T) {
resetViper()
cfg := NewDefault()
cfg := config.NewDefault()
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
BindFlags(fs, cfg)
@@ -83,7 +85,7 @@ func TestBindFlags(t *testing.T) {
func TestBindFlags_DefaultValues(t *testing.T) {
resetViper()
cfg := NewDefault()
cfg := config.NewDefault()
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
BindFlags(fs, cfg)
@@ -96,8 +98,8 @@ func TestBindFlags_DefaultValues(t *testing.T) {
t.Fatalf("ApplyFlags() error = %v", err)
}
if cfg.ReloadStrategy != ReloadStrategyEnvVars {
t.Errorf("ReloadStrategy = %v, want %v", cfg.ReloadStrategy, ReloadStrategyEnvVars)
if cfg.ReloadStrategy != config.ReloadStrategyEnvVars {
t.Errorf("ReloadStrategy = %v, want %v", cfg.ReloadStrategy, config.ReloadStrategyEnvVars)
}
if cfg.LogLevel != "info" {
@@ -107,7 +109,7 @@ func TestBindFlags_DefaultValues(t *testing.T) {
func TestBindFlags_CustomValues(t *testing.T) {
resetViper()
cfg := NewDefault()
cfg := config.NewDefault()
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
BindFlags(fs, cfg)
@@ -134,8 +136,8 @@ func TestBindFlags_CustomValues(t *testing.T) {
t.Error("AutoReloadAll should be true")
}
if cfg.ReloadStrategy != ReloadStrategyAnnotations {
t.Errorf("ReloadStrategy = %v, want %v", cfg.ReloadStrategy, ReloadStrategyAnnotations)
if cfg.ReloadStrategy != config.ReloadStrategyAnnotations {
t.Errorf("ReloadStrategy = %v, want %v", cfg.ReloadStrategy, config.ReloadStrategyAnnotations)
}
if cfg.LogLevel != "debug" {
@@ -162,7 +164,7 @@ func TestBindFlags_CustomValues(t *testing.T) {
func TestApplyFlags_SecretProviderClassAnnotations(t *testing.T) {
// Defaults are preserved when the flags are not provided.
resetViper()
cfg := NewDefault()
cfg := config.NewDefault()
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
BindFlags(fs, cfg)
if err := fs.Parse(nil); err != nil {
@@ -171,7 +173,7 @@ func TestApplyFlags_SecretProviderClassAnnotations(t *testing.T) {
if err := ApplyFlags(cfg); err != nil {
t.Fatalf("ApplyFlags() error = %v", err)
}
defaults := DefaultAnnotations()
defaults := config.DefaultAnnotations()
if cfg.Annotations.SecretProviderClassAuto != defaults.SecretProviderClassAuto {
t.Errorf("SecretProviderClassAuto = %q, want default %q", cfg.Annotations.SecretProviderClassAuto, defaults.SecretProviderClassAuto)
}
@@ -184,7 +186,7 @@ func TestApplyFlags_SecretProviderClassAnnotations(t *testing.T) {
// Custom values are applied from the flags.
resetViper()
cfg = NewDefault()
cfg = config.NewDefault()
fs = pflag.NewFlagSet("test", pflag.ContinueOnError)
BindFlags(fs, cfg)
args := []string{
@@ -212,7 +214,7 @@ func TestApplyFlags_SecretProviderClassAnnotations(t *testing.T) {
func TestApplyFlags_ExcludeAnnotations(t *testing.T) {
// Defaults are preserved when the flags are not provided.
resetViper()
cfg := NewDefault()
cfg := config.NewDefault()
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
BindFlags(fs, cfg)
if err := fs.Parse(nil); err != nil {
@@ -221,7 +223,7 @@ func TestApplyFlags_ExcludeAnnotations(t *testing.T) {
if err := ApplyFlags(cfg); err != nil {
t.Fatalf("ApplyFlags() error = %v", err)
}
defaults := DefaultAnnotations()
defaults := config.DefaultAnnotations()
if cfg.Annotations.ConfigmapExclude != defaults.ConfigmapExclude {
t.Errorf("ConfigmapExclude = %q, want default %q", cfg.Annotations.ConfigmapExclude, defaults.ConfigmapExclude)
}
@@ -231,7 +233,7 @@ func TestApplyFlags_ExcludeAnnotations(t *testing.T) {
// Custom values are applied from the flags.
resetViper()
cfg = NewDefault()
cfg = config.NewDefault()
fs = pflag.NewFlagSet("test", pflag.ContinueOnError)
BindFlags(fs, cfg)
args := []string{
@@ -255,7 +257,7 @@ func TestApplyFlags_ExcludeAnnotations(t *testing.T) {
func TestApplyFlags_IgnoreAnnotation(t *testing.T) {
// Default is preserved when the flag is not provided.
resetViper()
cfg := NewDefault()
cfg := config.NewDefault()
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
BindFlags(fs, cfg)
if err := fs.Parse(nil); err != nil {
@@ -264,13 +266,13 @@ func TestApplyFlags_IgnoreAnnotation(t *testing.T) {
if err := ApplyFlags(cfg); err != nil {
t.Fatalf("ApplyFlags() error = %v", err)
}
if cfg.Annotations.Ignore != DefaultAnnotations().Ignore {
t.Errorf("Ignore = %q, want default %q", cfg.Annotations.Ignore, DefaultAnnotations().Ignore)
if cfg.Annotations.Ignore != config.DefaultAnnotations().Ignore {
t.Errorf("Ignore = %q, want default %q", cfg.Annotations.Ignore, config.DefaultAnnotations().Ignore)
}
// Custom value is applied from the flag.
resetViper()
cfg = NewDefault()
cfg = config.NewDefault()
fs = pflag.NewFlagSet("test", pflag.ContinueOnError)
BindFlags(fs, cfg)
if err := fs.Parse([]string{"--ignore-annotation=my.company.com/reloader-ignore"}); err != nil {
@@ -305,7 +307,7 @@ func TestApplyFlags_BooleanStrings(t *testing.T) {
t.Run(
tt.name, func(t *testing.T) {
resetViper()
cfg := NewDefault()
cfg := config.NewDefault()
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
BindFlags(fs, cfg)
@@ -329,7 +331,7 @@ func TestApplyFlags_BooleanStrings(t *testing.T) {
func TestApplyFlags_CommaSeparatedLists(t *testing.T) {
resetViper()
cfg := NewDefault()
cfg := config.NewDefault()
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
BindFlags(fs, cfg)
@@ -365,7 +367,7 @@ func TestApplyFlags_CommaSeparatedLists(t *testing.T) {
func TestApplyFlags_Selectors(t *testing.T) {
resetViper()
cfg := NewDefault()
cfg := config.NewDefault()
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
BindFlags(fs, cfg)
@@ -397,7 +399,7 @@ func TestApplyFlags_Selectors(t *testing.T) {
func TestApplyFlags_InvalidSelector(t *testing.T) {
resetViper()
cfg := NewDefault()
cfg := config.NewDefault()
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
BindFlags(fs, cfg)
@@ -453,7 +455,7 @@ func TestApplyFlags_AlertingEnvVars(t *testing.T) {
t.Setenv(k, val)
}
cfg := NewDefault()
cfg := config.NewDefault()
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
BindFlags(fs, cfg)
@@ -486,7 +488,7 @@ func TestApplyFlags_LegacyProxyEnvVar(t *testing.T) {
t.Setenv("ALERT_WEBHOOK_PROXY", "http://legacy-proxy:8080")
cfg := NewDefault()
cfg := config.NewDefault()
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
BindFlags(fs, cfg)
@@ -505,7 +507,7 @@ func TestApplyFlags_LegacyProxyEnvVar(t *testing.T) {
func TestApplyFlagsCSIIntegration(t *testing.T) {
resetViper()
cfg := NewDefault()
cfg := config.NewDefault()
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
BindFlags(fs, cfg)
if err := fs.Parse([]string{"--enable-csi-integration=true"}); err != nil {
-160
View File
@@ -1,160 +0,0 @@
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
@@ -1,339 +0,0 @@
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])
}
}
},
)
}
}
@@ -9,10 +9,10 @@ import (
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"github.com/stakater/Reloader/internal/pkg/alerting"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/pkg/config"
"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/pkg/reload"
"github.com/stakater/Reloader/internal/pkg/webhook"
"github.com/stakater/Reloader/internal/pkg/workload"
)
@@ -3,7 +3,7 @@ package controller_test
import (
"testing"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/pkg/config"
"github.com/stakater/Reloader/internal/pkg/testutil"
)
@@ -10,8 +10,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/predicate"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/internal/pkg/reload"
"github.com/stakater/Reloader/pkg/config"
"github.com/stakater/Reloader/pkg/reload"
)
// DeploymentReconciler reconciles Deployment objects to handle pause expiration.
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/event"
"sigs.k8s.io/controller-runtime/pkg/predicate"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/internal/pkg/reload"
"github.com/stakater/Reloader/pkg/config"
"github.com/stakater/Reloader/pkg/reload"
)
// BuildEventFilter combines a resource-specific predicate with common filters.
+1 -1
View File
@@ -8,7 +8,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/event"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/pkg/config"
)
func TestCreateEventPredicate_CreateEvent(t *testing.T) {
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"github.com/stakater/Reloader/internal/pkg/alerting"
"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/pkg/reload"
"github.com/stakater/Reloader/internal/pkg/webhook"
"github.com/stakater/Reloader/internal/pkg/workload"
)
+2 -2
View File
@@ -18,10 +18,10 @@ import (
csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1"
"github.com/stakater/Reloader/internal/pkg/alerting"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/pkg/config"
"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/pkg/reload"
"github.com/stakater/Reloader/internal/pkg/webhook"
"github.com/stakater/Reloader/internal/pkg/workload"
)
@@ -11,8 +11,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/internal/pkg/reload"
"github.com/stakater/Reloader/pkg/config"
"github.com/stakater/Reloader/pkg/reload"
)
// NamespaceCache provides thread-safe access to the set of namespaces
@@ -5,7 +5,7 @@ import (
"k8s.io/apimachinery/pkg/labels"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/pkg/config"
"github.com/stakater/Reloader/internal/pkg/controller"
"github.com/stakater/Reloader/internal/pkg/testutil"
)
@@ -11,10 +11,10 @@ import (
"sigs.k8s.io/controller-runtime/pkg/predicate"
"github.com/stakater/Reloader/internal/pkg/alerting"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/pkg/config"
"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/pkg/reload"
"github.com/stakater/Reloader/internal/pkg/webhook"
"github.com/stakater/Reloader/internal/pkg/workload"
)
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"k8s.io/client-go/util/retry"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/stakater/Reloader/internal/pkg/reload"
"github.com/stakater/Reloader/pkg/reload"
"github.com/stakater/Reloader/internal/pkg/workload"
)
+2 -2
View File
@@ -13,9 +13,9 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/pkg/config"
"github.com/stakater/Reloader/internal/pkg/controller"
"github.com/stakater/Reloader/internal/pkg/reload"
"github.com/stakater/Reloader/pkg/reload"
"github.com/stakater/Reloader/internal/pkg/testutil"
"github.com/stakater/Reloader/internal/pkg/workload"
)
+2 -2
View File
@@ -9,10 +9,10 @@ import (
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"github.com/stakater/Reloader/internal/pkg/alerting"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/pkg/config"
"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/pkg/reload"
"github.com/stakater/Reloader/internal/pkg/webhook"
"github.com/stakater/Reloader/internal/pkg/workload"
)
@@ -3,7 +3,7 @@ package controller_test
import (
"testing"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/pkg/config"
"github.com/stakater/Reloader/internal/pkg/testutil"
)
@@ -8,8 +8,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/event"
csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/internal/pkg/reload"
"github.com/stakater/Reloader/pkg/config"
"github.com/stakater/Reloader/pkg/reload"
)
// TestSecretProviderClassReconciler_FilterIgnoresResourceLabelSelector pins the
@@ -12,8 +12,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/reconcile"
csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/internal/pkg/reload"
"github.com/stakater/Reloader/pkg/config"
"github.com/stakater/Reloader/pkg/reload"
)
// SecretProviderClassReconciler watches SecretProviderClassPodStatus (the per-pod
@@ -10,9 +10,9 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/pkg/config"
"github.com/stakater/Reloader/internal/pkg/controller"
"github.com/stakater/Reloader/internal/pkg/reload"
"github.com/stakater/Reloader/pkg/reload"
"github.com/stakater/Reloader/internal/pkg/testutil"
)
+2 -2
View File
@@ -13,11 +13,11 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"github.com/stakater/Reloader/internal/pkg/alerting"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/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/pkg/reload"
"github.com/stakater/Reloader/internal/pkg/testutil"
"github.com/stakater/Reloader/internal/pkg/webhook"
"github.com/stakater/Reloader/internal/pkg/workload"
-125
View File
@@ -1,125 +0,0 @@
// Package metadata provides metadata ConfigMap creation for Reloader.
// The metadata ConfigMap contains build info, configuration options, and deployment info.
package metadata
import (
"encoding/json"
"os"
"runtime"
"time"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/stakater/Reloader/internal/pkg/config"
)
const (
// ConfigMapName is the name of the metadata ConfigMap.
ConfigMapName = "reloader-meta-info"
// ConfigMapLabelKey is the label key for the metadata ConfigMap.
ConfigMapLabelKey = "reloader.stakater.com/meta-info"
// ConfigMapLabelValue is the label value for the metadata ConfigMap.
ConfigMapLabelValue = "reloader-oss"
// Environment variables for deployment info.
EnvReloaderNamespace = "RELOADER_NAMESPACE"
EnvReloaderDeploymentName = "RELOADER_DEPLOYMENT_NAME"
)
// Version, Commit, and BuildDate are set during the build process
// using the -X linker flag to inject these values into the binary.
var (
Version = "dev"
Commit = "unknown"
BuildDate = "unknown"
)
// MetaInfo contains comprehensive metadata about the Reloader instance.
type MetaInfo struct {
// BuildInfo contains information about the build version, commit, and compilation details.
BuildInfo BuildInfo `json:"buildInfo"`
// Config contains all the configuration options used by this Reloader instance.
Config *config.Config `json:"config"`
// DeploymentInfo contains metadata about the Kubernetes deployment of this instance.
DeploymentInfo DeploymentInfo `json:"deploymentInfo"`
}
// BuildInfo contains information about the build and version of the Reloader binary.
type BuildInfo struct {
// GoVersion is the version of Go used to compile the binary.
GoVersion string `json:"goVersion"`
// ReleaseVersion is the version tag or branch of the Reloader release.
ReleaseVersion string `json:"releaseVersion"`
// CommitHash is the Git commit hash of the source code used to build this binary.
CommitHash string `json:"commitHash"`
// CommitTime is the timestamp of the Git commit used to build this binary.
CommitTime time.Time `json:"commitTime"`
}
// DeploymentInfo contains metadata about the Reloader deployment.
type DeploymentInfo struct {
// Name is the name of the Reloader deployment.
Name string `json:"name"`
// Namespace is the namespace where Reloader is deployed.
Namespace string `json:"namespace"`
}
// NewBuildInfo creates a new BuildInfo with current build information.
func NewBuildInfo() BuildInfo {
return BuildInfo{
GoVersion: runtime.Version(),
ReleaseVersion: Version,
CommitHash: Commit,
CommitTime: parseUTCTime(BuildDate),
}
}
// NewMetaInfo creates a new MetaInfo from configuration.
func NewMetaInfo(cfg *config.Config) *MetaInfo {
return &MetaInfo{
BuildInfo: NewBuildInfo(),
Config: cfg,
DeploymentInfo: DeploymentInfo{
Name: os.Getenv(EnvReloaderDeploymentName),
Namespace: os.Getenv(EnvReloaderNamespace),
},
}
}
// ToConfigMap converts MetaInfo to a Kubernetes ConfigMap.
func (m *MetaInfo) ToConfigMap() *corev1.ConfigMap {
return &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: ConfigMapName,
Namespace: m.DeploymentInfo.Namespace,
Labels: map[string]string{
ConfigMapLabelKey: ConfigMapLabelValue,
},
},
Data: map[string]string{
"buildInfo": toJSON(m.BuildInfo),
"config": toJSON(m.Config),
"deploymentInfo": toJSON(m.DeploymentInfo),
},
}
}
func toJSON(data interface{}) string {
jsonData, err := json.Marshal(data)
if err != nil {
return ""
}
return string(jsonData)
}
func parseUTCTime(value string) time.Time {
if value == "" {
return time.Time{}
}
t, err := time.Parse(time.RFC3339, value)
if err != nil {
return time.Time{}
}
return t
}
-307
View File
@@ -1,307 +0,0 @@
package metadata
import (
"context"
"encoding/json"
"testing"
"github.com/go-logr/logr"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"github.com/stakater/Reloader/internal/pkg/config"
)
// testLogger returns a no-op logger for testing.
func testLogger() logr.Logger {
return logr.Discard()
}
func TestNewBuildInfo(t *testing.T) {
oldVersion := Version
oldCommit := Commit
oldBuildDate := BuildDate
defer func() {
Version = oldVersion
Commit = oldCommit
BuildDate = oldBuildDate
}()
Version = "1.0.0"
Commit = "abc123"
BuildDate = "2024-01-01T12:00:00Z"
info := NewBuildInfo()
if info.ReleaseVersion != "1.0.0" {
t.Errorf("ReleaseVersion = %s, want 1.0.0", info.ReleaseVersion)
}
if info.CommitHash != "abc123" {
t.Errorf("CommitHash = %s, want abc123", info.CommitHash)
}
if info.GoVersion == "" {
t.Error("GoVersion should not be empty")
}
if info.CommitTime.IsZero() {
t.Error("CommitTime should not be zero")
}
}
func TestNewMetaInfo(t *testing.T) {
t.Setenv(EnvReloaderNamespace, "test-ns")
t.Setenv(EnvReloaderDeploymentName, "test-deploy")
cfg := config.NewDefault()
cfg.AutoReloadAll = true
cfg.ReloadStrategy = config.ReloadStrategyAnnotations
cfg.ArgoRolloutsEnabled = true
cfg.ReloadOnCreate = true
cfg.ReloadOnDelete = true
cfg.EnableHA = true
cfg.WebhookURL = "https://example.com/webhook"
cfg.LogFormat = "json"
cfg.LogLevel = "debug"
cfg.IgnoredResources = []string{"configmaps"}
cfg.IgnoredWorkloads = []string{"jobs"}
cfg.IgnoredNamespaces = []string{"kube-system"}
metaInfo := NewMetaInfo(cfg)
if !metaInfo.Config.AutoReloadAll {
t.Error("AutoReloadAll should be true")
}
if metaInfo.Config.ReloadStrategy != config.ReloadStrategyAnnotations {
t.Errorf("ReloadStrategy = %s, want annotations", metaInfo.Config.ReloadStrategy)
}
if !metaInfo.Config.ArgoRolloutsEnabled {
t.Error("ArgoRolloutsEnabled should be true")
}
if !metaInfo.Config.ReloadOnCreate {
t.Error("ReloadOnCreate should be true")
}
if !metaInfo.Config.ReloadOnDelete {
t.Error("ReloadOnDelete should be true")
}
if !metaInfo.Config.EnableHA {
t.Error("EnableHA should be true")
}
if metaInfo.Config.WebhookURL != "https://example.com/webhook" {
t.Errorf("WebhookURL = %s, want https://example.com/webhook", metaInfo.Config.WebhookURL)
}
if metaInfo.DeploymentInfo.Namespace != "test-ns" {
t.Errorf("DeploymentInfo.Namespace = %s, want test-ns", metaInfo.DeploymentInfo.Namespace)
}
if metaInfo.DeploymentInfo.Name != "test-deploy" {
t.Errorf("DeploymentInfo.Name = %s, want test-deploy", metaInfo.DeploymentInfo.Name)
}
}
func TestMetaInfo_ToConfigMap(t *testing.T) {
t.Setenv(EnvReloaderNamespace, "reloader-ns")
t.Setenv(EnvReloaderDeploymentName, "reloader-deploy")
cfg := config.NewDefault()
metaInfo := NewMetaInfo(cfg)
cm := metaInfo.ToConfigMap()
if cm.Name != ConfigMapName {
t.Errorf("Name = %s, want %s", cm.Name, ConfigMapName)
}
if cm.Namespace != "reloader-ns" {
t.Errorf("Namespace = %s, want reloader-ns", cm.Namespace)
}
if cm.Labels[ConfigMapLabelKey] != ConfigMapLabelValue {
t.Errorf("Label = %s, want %s", cm.Labels[ConfigMapLabelKey], ConfigMapLabelValue)
}
if _, ok := cm.Data["buildInfo"]; !ok {
t.Error("buildInfo data key missing")
}
if _, ok := cm.Data["config"]; !ok {
t.Error("config data key missing")
}
if _, ok := cm.Data["deploymentInfo"]; !ok {
t.Error("deploymentInfo data key missing")
}
// Verify buildInfo is valid JSON
var buildInfo BuildInfo
if err := json.Unmarshal([]byte(cm.Data["buildInfo"]), &buildInfo); err != nil {
t.Errorf("buildInfo is not valid JSON: %v", err)
}
var parsedConfig config.Config
if err := json.Unmarshal([]byte(cm.Data["config"]), &parsedConfig); err != nil {
t.Errorf("config is not valid JSON: %v", err)
}
// Verify deploymentInfo contains expected values
var deployInfo DeploymentInfo
if err := json.Unmarshal([]byte(cm.Data["deploymentInfo"]), &deployInfo); err != nil {
t.Errorf("deploymentInfo is not valid JSON: %v", err)
}
if deployInfo.Namespace != "reloader-ns" {
t.Errorf("DeploymentInfo.Namespace = %s, want reloader-ns", deployInfo.Namespace)
}
if deployInfo.Name != "reloader-deploy" {
t.Errorf("DeploymentInfo.Name = %s, want reloader-deploy", deployInfo.Name)
}
}
func TestPublisher_Publish_NoNamespace(t *testing.T) {
t.Setenv(EnvReloaderNamespace, "")
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build()
cfg := config.NewDefault()
publisher := NewPublisher(fakeClient, cfg, testLogger())
err := publisher.Publish(context.Background())
if err != nil {
t.Errorf("Publish() with no namespace should not error, got: %v", err)
}
}
func TestPublisher_Publish_CreateNew(t *testing.T) {
t.Setenv(EnvReloaderNamespace, "test-ns")
t.Setenv(EnvReloaderDeploymentName, "test-deploy")
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build()
cfg := config.NewDefault()
publisher := NewPublisher(fakeClient, cfg, testLogger())
ctx := context.Background()
err := publisher.Publish(ctx)
if err != nil {
t.Errorf("Publish() error = %v", err)
}
cm := &corev1.ConfigMap{}
err = fakeClient.Get(ctx, client.ObjectKey{Name: ConfigMapName, Namespace: "test-ns"}, cm)
if err != nil {
t.Errorf("Failed to get created ConfigMap: %v", err)
}
if cm.Name != ConfigMapName {
t.Errorf("ConfigMap.Name = %s, want %s", cm.Name, ConfigMapName)
}
}
func TestPublisher_Publish_UpdateExisting(t *testing.T) {
t.Setenv(EnvReloaderNamespace, "test-ns")
t.Setenv(EnvReloaderDeploymentName, "test-deploy")
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
existingCM := &corev1.ConfigMap{}
existingCM.Name = ConfigMapName
existingCM.Namespace = "test-ns"
existingCM.Data = map[string]string{
"buildInfo": `{"goVersion":"old"}`,
}
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(existingCM).
Build()
cfg := config.NewDefault()
publisher := NewPublisher(fakeClient, cfg, testLogger())
ctx := context.Background()
err := publisher.Publish(ctx)
if err != nil {
t.Errorf("Publish() error = %v", err)
}
cm := &corev1.ConfigMap{}
err = fakeClient.Get(ctx, client.ObjectKey{Name: ConfigMapName, Namespace: "test-ns"}, cm)
if err != nil {
t.Errorf("Failed to get updated ConfigMap: %v", err)
}
if _, ok := cm.Data["buildInfo"]; !ok {
t.Error("buildInfo data key missing after update")
}
if _, ok := cm.Data["config"]; !ok {
t.Error("config data key missing after update")
}
if _, ok := cm.Data["deploymentInfo"]; !ok {
t.Error("deploymentInfo data key missing after update")
}
if cm.Labels[ConfigMapLabelKey] != ConfigMapLabelValue {
t.Errorf("Label not updated: %s", cm.Labels[ConfigMapLabelKey])
}
}
func TestPublishMetaInfoConfigMap(t *testing.T) {
t.Setenv(EnvReloaderNamespace, "test-ns")
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build()
cfg := config.NewDefault()
ctx := context.Background()
err := PublishMetaInfoConfigMap(ctx, fakeClient, cfg, testLogger())
if err != nil {
t.Errorf("PublishMetaInfoConfigMap() error = %v", err)
}
cm := &corev1.ConfigMap{}
err = fakeClient.Get(ctx, client.ObjectKey{Name: ConfigMapName, Namespace: "test-ns"}, cm)
if err != nil {
t.Errorf("Failed to get created ConfigMap: %v", err)
}
}
func TestParseUTCTime(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
}{
{
name: "valid RFC3339 time",
input: "2024-01-01T12:00:00Z",
wantErr: false,
},
{
name: "empty string",
input: "",
wantErr: true, // returns zero time
},
{
name: "invalid format",
input: "not-a-time",
wantErr: true, // returns zero time
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
result := parseUTCTime(tt.input)
if tt.wantErr {
if !result.IsZero() {
t.Errorf("parseUTCTime(%s) should return zero time", tt.input)
}
} else {
if result.IsZero() {
t.Errorf("parseUTCTime(%s) should not return zero time", tt.input)
}
}
},
)
}
}
-99
View File
@@ -1,99 +0,0 @@
package metadata
import (
"context"
"fmt"
"os"
"github.com/go-logr/logr"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/internal/pkg/workload"
)
// Publisher handles creating and updating the metadata ConfigMap.
type Publisher struct {
client client.Client
cfg *config.Config
log logr.Logger
}
// NewPublisher creates a new Publisher.
func NewPublisher(c client.Client, cfg *config.Config, log logr.Logger) *Publisher {
return &Publisher{
client: c,
cfg: cfg,
log: log,
}
}
// Publish creates or updates the metadata ConfigMap.
func (p *Publisher) Publish(ctx context.Context) error {
namespace := os.Getenv(EnvReloaderNamespace)
if namespace == "" {
p.log.Info("RELOADER_NAMESPACE is not set, skipping meta info configmap creation")
return nil
}
metaInfo := NewMetaInfo(p.cfg)
configMap := metaInfo.ToConfigMap()
existing := &corev1.ConfigMap{}
err := p.client.Get(
ctx, client.ObjectKey{
Name: ConfigMapName,
Namespace: namespace,
}, existing,
)
if err != nil {
if !errors.IsNotFound(err) {
return fmt.Errorf("failed to get existing meta info configmap: %w", err)
}
p.log.Info("Creating meta info configmap")
if err := p.client.Create(ctx, configMap, client.FieldOwner(workload.FieldManager)); err != nil {
return fmt.Errorf("failed to create meta info configmap: %w", err)
}
p.log.Info("Meta info configmap created successfully")
return nil
}
p.log.Info("Meta info configmap already exists, updating it")
existing.Data = configMap.Data
existing.Labels = configMap.Labels
if err := p.client.Update(ctx, existing, client.FieldOwner(workload.FieldManager)); err != nil {
return fmt.Errorf("failed to update meta info configmap: %w", err)
}
p.log.Info("Meta info configmap updated successfully")
return nil
}
// PublishMetaInfoConfigMap is a convenience function that creates a Publisher and calls Publish.
func PublishMetaInfoConfigMap(ctx context.Context, c client.Client, cfg *config.Config, log logr.Logger) error {
publisher := NewPublisher(c, cfg, log)
return publisher.Publish(ctx)
}
// Runnable returns a controller-runtime Runnable that publishes the metadata ConfigMap
// when the manager starts. This ensures the cache is ready before accessing the API.
func Runnable(c client.Client, cfg *config.Config, log logr.Logger) RunnableFunc {
return func(ctx context.Context) error {
if err := PublishMetaInfoConfigMap(ctx, c, cfg, log); err != nil {
log.Error(err, "Failed to create metadata ConfigMap")
// Non-fatal, don't return error to avoid crashing the manager
}
<-ctx.Done()
return nil
}
}
// RunnableFunc is a function that implements the controller-runtime Runnable interface.
type RunnableFunc func(context.Context) error
// Start implements the Runnable interface.
func (r RunnableFunc) Start(ctx context.Context) error {
return r(ctx)
}
-82
View File
@@ -1,82 +0,0 @@
package reload
import (
corev1 "k8s.io/api/core/v1"
csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1"
)
// EventType represents the type of change event.
type EventType string
const (
// EventTypeCreate indicates a resource was created.
EventTypeCreate EventType = "create"
// EventTypeUpdate indicates a resource was updated.
EventTypeUpdate EventType = "update"
// EventTypeDelete indicates a resource was deleted.
EventTypeDelete EventType = "delete"
)
// ResourceChange represents a change event for a ConfigMap or Secret.
type ResourceChange interface {
IsNil() bool
GetEventType() EventType
GetName() string
GetNamespace() string
GetAnnotations() map[string]string
GetResourceType() ResourceType
ComputeHash(hasher *Hasher) string
}
// ConfigMapChange represents a change event for a ConfigMap.
type ConfigMapChange struct {
ConfigMap *corev1.ConfigMap
EventType EventType
}
func (c ConfigMapChange) IsNil() bool { return c.ConfigMap == nil }
func (c ConfigMapChange) GetEventType() EventType { return c.EventType }
func (c ConfigMapChange) GetName() string { return c.ConfigMap.Name }
func (c ConfigMapChange) GetNamespace() string { return c.ConfigMap.Namespace }
func (c ConfigMapChange) GetAnnotations() map[string]string { return c.ConfigMap.Annotations }
func (c ConfigMapChange) GetResourceType() ResourceType { return ResourceTypeConfigMap }
func (c ConfigMapChange) ComputeHash(h *Hasher) string { return h.HashConfigMap(c.ConfigMap) }
// SecretChange represents a change event for a Secret.
type SecretChange struct {
Secret *corev1.Secret
EventType EventType
}
func (c SecretChange) IsNil() bool { return c.Secret == nil }
func (c SecretChange) GetEventType() EventType { return c.EventType }
func (c SecretChange) GetName() string { return c.Secret.Name }
func (c SecretChange) GetNamespace() string { return c.Secret.Namespace }
func (c SecretChange) GetAnnotations() map[string]string { return c.Secret.Annotations }
func (c SecretChange) GetResourceType() ResourceType { return ResourceTypeSecret }
func (c SecretChange) ComputeHash(h *Hasher) string { return h.HashSecret(c.Secret) }
// SecretProviderClassChange represents a change event derived from a
// SecretProviderClassPodStatus update. Name/Annotations refer to the resolved
// SecretProviderClass; Status carries the SPCPS status used for hashing.
type SecretProviderClassChange struct {
Name string
Namespace string
Annotations map[string]string
Status csiv1.SecretProviderClassPodStatusStatus
EventType EventType
}
func (c SecretProviderClassChange) IsNil() bool { return c.Name == "" }
func (c SecretProviderClassChange) GetEventType() EventType { return c.EventType }
func (c SecretProviderClassChange) GetName() string { return c.Name }
func (c SecretProviderClassChange) GetNamespace() string { return c.Namespace }
func (c SecretProviderClassChange) GetAnnotations() map[string]string {
return c.Annotations
}
func (c SecretProviderClassChange) GetResourceType() ResourceType {
return ResourceTypeSecretProviderClass
}
func (c SecretProviderClassChange) ComputeHash(h *Hasher) string {
return h.HashSecretProviderClass(c.Status)
}
-51
View File
@@ -1,51 +0,0 @@
package reload
import (
"testing"
csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1"
)
func TestSecretProviderClassChange(t *testing.T) {
status := csiv1.SecretProviderClassPodStatusStatus{
SecretProviderClassName: "my-spc",
Objects: []csiv1.SecretProviderClassObject{{ID: "a", Version: "1"}},
}
c := SecretProviderClassChange{
Name: "my-spc",
Namespace: "ns1",
Annotations: map[string]string{"k": "v"},
Status: status,
EventType: EventTypeUpdate,
}
if c.IsNil() {
t.Fatal("IsNil() = true, want false")
}
if c.GetName() != "my-spc" {
t.Fatalf("GetName() = %q", c.GetName())
}
if c.GetNamespace() != "ns1" {
t.Fatalf("GetNamespace() = %q", c.GetNamespace())
}
if c.GetResourceType() != ResourceTypeSecretProviderClass {
t.Fatalf("GetResourceType() = %q", c.GetResourceType())
}
if c.GetEventType() != EventTypeUpdate {
t.Fatalf("GetEventType() = %q", c.GetEventType())
}
if c.GetAnnotations()["k"] != "v" {
t.Fatalf("GetAnnotations() missing key")
}
h := NewHasher()
if c.ComputeHash(h) != h.HashSecretProviderClass(status) {
t.Fatalf("ComputeHash mismatch")
}
}
func TestSecretProviderClassChangeIsNil(t *testing.T) {
c := SecretProviderClassChange{Name: ""}
if !c.IsNil() {
t.Fatal("IsNil() = false, want true for empty name")
}
}
-24
View File
@@ -1,24 +0,0 @@
package reload
import (
"testing"
csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1"
)
// TestCSIDependencyAvailable ensures the CSI types are importable and the
// fields this feature depends on exist.
func TestCSIDependencyAvailable(t *testing.T) {
status := csiv1.SecretProviderClassPodStatusStatus{
SecretProviderClassName: "spc",
Objects: []csiv1.SecretProviderClassObject{
{ID: "secret/data/foo", Version: "1"},
},
}
if status.SecretProviderClassName != "spc" {
t.Fatalf("unexpected SecretProviderClassName")
}
if len(status.Objects) != 1 || status.Objects[0].ID != "secret/data/foo" {
t.Fatalf("unexpected Objects")
}
}
-30
View File
@@ -1,30 +0,0 @@
package reload
import (
"github.com/stakater/Reloader/internal/pkg/workload"
)
// ReloadDecision contains the result of evaluating whether to reload a workload.
type ReloadDecision struct {
// Workload is the workload accessor.
Workload workload.Workload
// ShouldReload indicates whether the workload should be reloaded.
ShouldReload bool
// AutoReload indicates if this is an auto-reload.
AutoReload bool
// Reason provides a human-readable explanation.
Reason string
// Hash is the computed hash of the resource content.
Hash string
}
// FilterDecisions returns only decisions where ShouldReload is true.
func FilterDecisions(decisions []ReloadDecision) []ReloadDecision {
var result []ReloadDecision
for _, d := range decisions {
if d.ShouldReload {
result = append(result, d)
}
}
return result
}
-125
View File
@@ -1,125 +0,0 @@
package reload
import (
"testing"
appsv1 "k8s.io/api/apps/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/stakater/Reloader/internal/pkg/workload"
)
func TestFilterDecisions(t *testing.T) {
wl1 := workload.NewDeploymentWorkload(
&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: "deploy1", Namespace: "default"},
},
)
wl2 := workload.NewDeploymentWorkload(
&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: "deploy2", Namespace: "default"},
},
)
wl3 := workload.NewDeploymentWorkload(
&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: "deploy3", Namespace: "default"},
},
)
tests := []struct {
name string
decisions []ReloadDecision
wantCount int
wantNames []string
}{
{
name: "empty list",
decisions: []ReloadDecision{},
wantCount: 0,
wantNames: nil,
},
{
name: "all should reload",
decisions: []ReloadDecision{
{Workload: wl1, ShouldReload: true, Reason: "test"},
{Workload: wl2, ShouldReload: true, Reason: "test"},
},
wantCount: 2,
wantNames: []string{"deploy1", "deploy2"},
},
{
name: "none should reload",
decisions: []ReloadDecision{
{Workload: wl1, ShouldReload: false, Reason: "test"},
{Workload: wl2, ShouldReload: false, Reason: "test"},
},
wantCount: 0,
wantNames: nil,
},
{
name: "mixed - some should reload",
decisions: []ReloadDecision{
{Workload: wl1, ShouldReload: true, Reason: "test"},
{Workload: wl2, ShouldReload: false, Reason: "test"},
{Workload: wl3, ShouldReload: true, Reason: "test"},
},
wantCount: 2,
wantNames: []string{"deploy1", "deploy3"},
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
result := FilterDecisions(tt.decisions)
if len(result) != tt.wantCount {
t.Errorf("FilterDecisions() returned %d decisions, want %d", len(result), tt.wantCount)
}
if tt.wantNames != nil {
for i, d := range result {
if d.Workload.GetName() != tt.wantNames[i] {
t.Errorf(
"FilterDecisions()[%d].Workload.GetName() = %s, want %s",
i, d.Workload.GetName(), tt.wantNames[i],
)
}
}
}
},
)
}
}
func TestReloadDecision_Fields(t *testing.T) {
wl := workload.NewDeploymentWorkload(
&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
},
)
decision := ReloadDecision{
Workload: wl,
ShouldReload: true,
AutoReload: true,
Reason: "test reason",
Hash: "abc123",
}
if decision.Workload.GetName() != "test" {
t.Errorf("ReloadDecision.Workload.GetName() = %v, want test", decision.Workload.GetName())
}
if !decision.ShouldReload {
t.Error("ReloadDecision.ShouldReload should be true")
}
if !decision.AutoReload {
t.Error("ReloadDecision.AutoReload should be true")
}
if decision.Reason != "test reason" {
t.Errorf("ReloadDecision.Reason = %v, want 'test reason'", decision.Reason)
}
if decision.Hash != "abc123" {
t.Errorf("ReloadDecision.Hash = %v, want 'abc123'", decision.Hash)
}
}
-88
View File
@@ -1,88 +0,0 @@
// Package reload provides core reload logic for ConfigMaps and Secrets.
package reload
import (
"crypto/sha1"
"encoding/base64"
"fmt"
"io"
"sort"
"strings"
corev1 "k8s.io/api/core/v1"
csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1"
)
// Hasher computes content hashes for ConfigMaps and Secrets.
type Hasher struct{}
// NewHasher creates a new Hasher instance.
func NewHasher() *Hasher {
return &Hasher{}
}
// HashConfigMap computes a SHA1 hash of the ConfigMap's data and binaryData.
func (h *Hasher) HashConfigMap(cm *corev1.ConfigMap) string {
if cm == nil {
return h.computeSHA("")
}
return h.hashConfigMapData(cm.Data, cm.BinaryData)
}
// HashSecret computes a SHA1 hash of the Secret's data.
func (h *Hasher) HashSecret(secret *corev1.Secret) string {
if secret == nil {
return h.computeSHA("")
}
return h.hashSecretData(secret.Data)
}
func (h *Hasher) hashConfigMapData(data map[string]string, binaryData map[string][]byte) string {
values := make([]string, 0, len(data)+len(binaryData))
for k, v := range data {
values = append(values, k+"="+v)
}
for k, v := range binaryData {
values = append(values, k+"="+base64.StdEncoding.EncodeToString(v))
}
sort.Strings(values)
return h.computeSHA(strings.Join(values, ";"))
}
func (h *Hasher) hashSecretData(data map[string][]byte) string {
values := make([]string, 0, len(data))
for k, v := range data {
values = append(values, k+"="+string(v))
}
sort.Strings(values)
return h.computeSHA(strings.Join(values, ";"))
}
func (h *Hasher) computeSHA(data string) string {
hasher := sha1.New()
_, _ = io.WriteString(hasher, data)
return fmt.Sprintf("%x", hasher.Sum(nil))
}
// HashSecretProviderClass computes a SHA1 hash of a SecretProviderClassPodStatus
// status: the sorted set of object ID=Version entries plus the SPC name.
// This mirrors master's util.GetSHAfromSecretProviderClassPodStatus exactly.
func (h *Hasher) HashSecretProviderClass(status csiv1.SecretProviderClassPodStatusStatus) string {
values := make([]string, 0, len(status.Objects)+1)
for _, obj := range status.Objects {
values = append(values, obj.ID+"="+obj.Version)
}
values = append(values, "SecretProviderClassName="+status.SecretProviderClassName)
sort.Strings(values)
return h.computeSHA(strings.Join(values, ";"))
}
// EmptyHash returns an empty string to signal resource deletion.
func (h *Hasher) EmptyHash() string {
return ""
}
-279
View File
@@ -1,279 +0,0 @@
package reload
import (
"testing"
corev1 "k8s.io/api/core/v1"
csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1"
)
func TestHasher_HashConfigMap(t *testing.T) {
hasher := NewHasher()
tests := []struct {
name string
cm *corev1.ConfigMap
wantHash string
}{
{
name: "empty configmap",
cm: &corev1.ConfigMap{
Data: nil,
BinaryData: nil,
},
wantHash: hasher.HashConfigMap(&corev1.ConfigMap{}),
},
{
name: "configmap with data",
cm: &corev1.ConfigMap{
Data: map[string]string{
"key1": "value1",
"key2": "value2",
},
},
wantHash: hasher.HashConfigMap(
&corev1.ConfigMap{
Data: map[string]string{
"key1": "value1",
"key2": "value2",
},
},
),
},
{
name: "configmap with binary data",
cm: &corev1.ConfigMap{
BinaryData: map[string][]byte{
"binary1": []byte("binaryvalue1"),
},
},
wantHash: hasher.HashConfigMap(
&corev1.ConfigMap{
BinaryData: map[string][]byte{
"binary1": []byte("binaryvalue1"),
},
},
),
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
got := hasher.HashConfigMap(tt.cm)
if got != tt.wantHash {
t.Errorf("HashConfigMap() = %v, want %v", got, tt.wantHash)
}
},
)
}
}
func TestHasher_HashConfigMap_Deterministic(t *testing.T) {
hasher := NewHasher()
cm := &corev1.ConfigMap{
Data: map[string]string{
"z-key": "value-z",
"a-key": "value-a",
"m-key": "value-m",
},
}
hash1 := hasher.HashConfigMap(cm)
hash2 := hasher.HashConfigMap(cm)
hash3 := hasher.HashConfigMap(cm)
if hash1 != hash2 || hash2 != hash3 {
t.Errorf("Hash is not deterministic: %s, %s, %s", hash1, hash2, hash3)
}
}
func TestHasher_HashConfigMap_DifferentValues(t *testing.T) {
hasher := NewHasher()
cm1 := &corev1.ConfigMap{
Data: map[string]string{
"key": "value1",
},
}
cm2 := &corev1.ConfigMap{
Data: map[string]string{
"key": "value2",
},
}
hash1 := hasher.HashConfigMap(cm1)
hash2 := hasher.HashConfigMap(cm2)
if hash1 == hash2 {
t.Errorf("Different values should produce different hashes")
}
}
func TestHasher_HashSecret(t *testing.T) {
hasher := NewHasher()
tests := []struct {
name string
secret *corev1.Secret
wantHash string
}{
{
name: "empty secret",
secret: &corev1.Secret{
Data: nil,
},
wantHash: hasher.HashSecret(&corev1.Secret{}),
},
{
name: "secret with data",
secret: &corev1.Secret{
Data: map[string][]byte{
"key1": []byte("value1"),
"key2": []byte("value2"),
},
},
wantHash: hasher.HashSecret(
&corev1.Secret{
Data: map[string][]byte{
"key1": []byte("value1"),
"key2": []byte("value2"),
},
},
),
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
got := hasher.HashSecret(tt.secret)
if got != tt.wantHash {
t.Errorf("HashSecret() = %v, want %v", got, tt.wantHash)
}
},
)
}
}
func TestHasher_HashSecret_Deterministic(t *testing.T) {
hasher := NewHasher()
secret := &corev1.Secret{
Data: map[string][]byte{
"z-key": []byte("value-z"),
"a-key": []byte("value-a"),
"m-key": []byte("value-m"),
},
}
hash1 := hasher.HashSecret(secret)
hash2 := hasher.HashSecret(secret)
hash3 := hasher.HashSecret(secret)
if hash1 != hash2 || hash2 != hash3 {
t.Errorf("Hash is not deterministic: %s, %s, %s", hash1, hash2, hash3)
}
}
func TestHasher_HashSecret_DifferentValues(t *testing.T) {
hasher := NewHasher()
secret1 := &corev1.Secret{
Data: map[string][]byte{
"key": []byte("value1"),
},
}
secret2 := &corev1.Secret{
Data: map[string][]byte{
"key": []byte("value2"),
},
}
hash1 := hasher.HashSecret(secret1)
hash2 := hasher.HashSecret(secret2)
if hash1 == hash2 {
t.Errorf("Different values should produce different hashes")
}
}
func TestHasher_EmptyHash(t *testing.T) {
hasher := NewHasher()
emptyHash := hasher.EmptyHash()
if emptyHash != "" {
t.Errorf("EmptyHash should be empty string, got %s", emptyHash)
}
cm := &corev1.ConfigMap{}
cmHash := hasher.HashConfigMap(cm)
if cmHash == "" {
t.Error("Empty ConfigMap should have a non-empty hash")
}
secret := &corev1.Secret{}
secretHash := hasher.HashSecret(secret)
if secretHash == "" {
t.Error("Empty Secret should have a non-empty hash")
}
}
func TestHasher_NilInput(t *testing.T) {
hasher := NewHasher()
cmHash := hasher.HashConfigMap(nil)
if cmHash == "" {
t.Error("nil ConfigMap should return a valid hash")
}
secretHash := hasher.HashSecret(nil)
if secretHash == "" {
t.Error("nil Secret should return a valid hash")
}
}
func TestHashSecretProviderClass(t *testing.T) {
h := NewHasher()
status := csiv1.SecretProviderClassPodStatusStatus{
SecretProviderClassName: "my-spc",
Objects: []csiv1.SecretProviderClassObject{
{ID: "secret/data/b", Version: "2"},
{ID: "secret/data/a", Version: "1"},
},
}
// Expected = SHA1 hex of the sorted, ';'-joined string, matching master.
expectedInput := "SecretProviderClassName=my-spc;secret/data/a=1;secret/data/b=2"
expected := h.computeSHA(expectedInput)
got := h.HashSecretProviderClass(status)
if got != expected {
t.Fatalf("HashSecretProviderClass = %q, want %q", got, expected)
}
// Order independence: shuffling objects must not change the hash.
statusReordered := csiv1.SecretProviderClassPodStatusStatus{
SecretProviderClassName: "my-spc",
Objects: []csiv1.SecretProviderClassObject{
{ID: "secret/data/a", Version: "1"},
{ID: "secret/data/b", Version: "2"},
},
}
if h.HashSecretProviderClass(statusReordered) != got {
t.Fatalf("hash not order-independent")
}
}
func TestHashSecretProviderClassEmpty(t *testing.T) {
h := NewHasher()
status := csiv1.SecretProviderClassPodStatusStatus{SecretProviderClassName: "empty"}
got := h.HashSecretProviderClass(status)
want := h.computeSHA("SecretProviderClassName=empty")
if got != want {
t.Fatalf("HashSecretProviderClass(empty) = %q, want %q", got, want)
}
}
-265
View File
@@ -1,265 +0,0 @@
package reload
import (
"regexp"
"strings"
"github.com/stakater/Reloader/internal/pkg/config"
)
// MatchResult contains the result of checking if a workload should be reloaded.
type MatchResult struct {
ShouldReload bool
AutoReload bool
Reason string
}
// Matcher determines whether a workload should be reloaded based on annotations.
type Matcher struct {
cfg *config.Config
}
// NewMatcher creates a new Matcher with the given configuration.
func NewMatcher(cfg *config.Config) *Matcher {
return &Matcher{cfg: cfg}
}
// MatchInput contains all the information needed to determine if a reload should occur.
type MatchInput struct {
ResourceName string
ResourceNamespace string
ResourceType ResourceType
ResourceAnnotations map[string]string
WorkloadAnnotations map[string]string
PodAnnotations map[string]string
}
// ShouldReload determines if a workload should be reloaded based on its annotations.
func (m *Matcher) ShouldReload(input MatchInput) MatchResult {
if m.isResourceIgnored(input.ResourceAnnotations) {
return MatchResult{
ShouldReload: false,
Reason: "resource has ignore annotation",
}
}
annotations := m.selectAnnotations(input)
if m.isResourceExcluded(input.ResourceName, input.ResourceType, annotations) {
return MatchResult{
ShouldReload: false,
Reason: "resource is in exclude list",
}
}
if m.matchesExplicitAnnotation(input.ResourceName, input.ResourceType, annotations) {
return MatchResult{
ShouldReload: true,
AutoReload: false,
Reason: "matches explicit reload annotation",
}
}
if m.matchesSearchPattern(input.ResourceAnnotations, annotations) {
return MatchResult{
ShouldReload: true,
AutoReload: true,
Reason: "matches search/match pattern",
}
}
if m.matchesAutoAnnotation(input.ResourceType, annotations) {
return MatchResult{
ShouldReload: true,
AutoReload: true,
Reason: "auto annotation enabled",
}
}
if m.matchesAutoReloadAll(input.ResourceType, annotations) {
return MatchResult{
ShouldReload: true,
AutoReload: true,
Reason: "auto-reload-all enabled",
}
}
return MatchResult{
ShouldReload: false,
Reason: "no matching annotations",
}
}
func (m *Matcher) isResourceIgnored(resourceAnnotations map[string]string) bool {
if resourceAnnotations == nil {
return false
}
return resourceAnnotations[m.cfg.Annotations.Ignore] == "true"
}
func (m *Matcher) selectAnnotations(input MatchInput) map[string]string {
if m.hasRelevantAnnotations(input.WorkloadAnnotations, input.ResourceType) {
return input.WorkloadAnnotations
}
if m.hasRelevantAnnotations(input.PodAnnotations, input.ResourceType) {
return input.PodAnnotations
}
return input.WorkloadAnnotations
}
func (m *Matcher) hasRelevantAnnotations(annotations map[string]string, resourceType ResourceType) bool {
if annotations == nil {
return false
}
explicitAnn := m.getExplicitAnnotation(resourceType)
if _, ok := annotations[explicitAnn]; ok {
return true
}
if _, ok := annotations[m.cfg.Annotations.Search]; ok {
return true
}
if _, ok := annotations[m.cfg.Annotations.Auto]; ok {
return true
}
typedAutoAnn := m.getTypedAutoAnnotation(resourceType)
if _, ok := annotations[typedAutoAnn]; ok {
return true
}
return false
}
func (m *Matcher) isResourceExcluded(resourceName string, resourceType ResourceType, annotations map[string]string) bool {
if annotations == nil {
return false
}
var excludeAnn string
switch resourceType {
case ResourceTypeConfigMap:
excludeAnn = m.cfg.Annotations.ConfigmapExclude
case ResourceTypeSecret:
excludeAnn = m.cfg.Annotations.SecretExclude
case ResourceTypeSecretProviderClass:
excludeAnn = m.cfg.Annotations.SecretProviderClassExclude
}
excludeList, ok := annotations[excludeAnn]
if !ok || excludeList == "" {
return false
}
for _, excluded := range strings.Split(excludeList, ",") {
if strings.TrimSpace(excluded) == resourceName {
return true
}
}
return false
}
func (m *Matcher) matchesExplicitAnnotation(resourceName string, resourceType ResourceType, annotations map[string]string) bool {
if annotations == nil {
return false
}
explicitAnn := m.getExplicitAnnotation(resourceType)
annotationValue, ok := annotations[explicitAnn]
if !ok || annotationValue == "" {
return false
}
for _, value := range strings.Split(annotationValue, ",") {
value = strings.TrimSpace(value)
if value == "" {
continue
}
re, err := regexp.Compile("^" + value + "$")
if err != nil {
if value == resourceName {
return true
}
continue
}
if re.MatchString(resourceName) {
return true
}
}
return false
}
func (m *Matcher) matchesSearchPattern(resourceAnnotations, workloadAnnotations map[string]string) bool {
if workloadAnnotations == nil || resourceAnnotations == nil {
return false
}
searchValue, ok := workloadAnnotations[m.cfg.Annotations.Search]
if !ok || searchValue != "true" {
return false
}
matchValue, ok := resourceAnnotations[m.cfg.Annotations.Match]
return ok && matchValue == "true"
}
func (m *Matcher) matchesAutoAnnotation(resourceType ResourceType, annotations map[string]string) bool {
if annotations == nil {
return false
}
if annotations[m.cfg.Annotations.Auto] == "true" {
return true
}
typedAutoAnn := m.getTypedAutoAnnotation(resourceType)
return annotations[typedAutoAnn] == "true"
}
func (m *Matcher) matchesAutoReloadAll(resourceType ResourceType, annotations map[string]string) bool {
if !m.cfg.AutoReloadAll {
return false
}
if annotations != nil {
if annotations[m.cfg.Annotations.Auto] == "false" {
return false
}
typedAutoAnn := m.getTypedAutoAnnotation(resourceType)
if annotations[typedAutoAnn] == "false" {
return false
}
}
return true
}
func (m *Matcher) getExplicitAnnotation(resourceType ResourceType) string {
switch resourceType {
case ResourceTypeConfigMap:
return m.cfg.Annotations.ConfigmapReload
case ResourceTypeSecret:
return m.cfg.Annotations.SecretReload
case ResourceTypeSecretProviderClass:
return m.cfg.Annotations.SecretProviderClassReload
default:
return ""
}
}
func (m *Matcher) getTypedAutoAnnotation(resourceType ResourceType) string {
switch resourceType {
case ResourceTypeConfigMap:
return m.cfg.Annotations.ConfigmapAuto
case ResourceTypeSecret:
return m.cfg.Annotations.SecretAuto
case ResourceTypeSecretProviderClass:
return m.cfg.Annotations.SecretProviderClassAuto
default:
return ""
}
}
-520
View File
@@ -1,520 +0,0 @@
package reload
import (
"testing"
"github.com/stakater/Reloader/internal/pkg/config"
)
func TestMatcher_ShouldReload(t *testing.T) {
defaultCfg := config.NewDefault()
matcher := NewMatcher(defaultCfg)
tests := []struct {
name string
input MatchInput
wantReload bool
wantAutoReload bool
description string
}{
{
name: "ignore annotation on resource skips reload",
input: MatchInput{
ResourceName: "my-config",
ResourceNamespace: "default",
ResourceType: ResourceTypeConfigMap,
ResourceAnnotations: map[string]string{"reloader.stakater.com/ignore": "true"},
WorkloadAnnotations: map[string]string{"reloader.stakater.com/auto": "true"},
PodAnnotations: nil,
},
wantReload: false,
wantAutoReload: false,
description: "Resources with ignore annotation should never trigger reload",
},
{
name: "ignore annotation false allows reload",
input: MatchInput{
ResourceName: "my-config",
ResourceNamespace: "default",
ResourceType: ResourceTypeConfigMap,
ResourceAnnotations: map[string]string{"reloader.stakater.com/ignore": "false"},
WorkloadAnnotations: map[string]string{"reloader.stakater.com/auto": "true"},
PodAnnotations: nil,
},
wantReload: true,
wantAutoReload: true,
description: "Resources with ignore=false should allow reload",
},
{
name: "exclude annotation skips reload",
input: MatchInput{
ResourceName: "my-config",
ResourceNamespace: "default",
ResourceType: ResourceTypeConfigMap,
ResourceAnnotations: nil,
WorkloadAnnotations: map[string]string{
"reloader.stakater.com/auto": "true",
"configmaps.exclude.reloader.stakater.com/reload": "my-config",
},
PodAnnotations: nil,
},
wantReload: false,
wantAutoReload: false,
description: "Excluded ConfigMaps should not trigger reload",
},
{
name: "exclude annotation with multiple values",
input: MatchInput{
ResourceName: "my-config",
ResourceNamespace: "default",
ResourceType: ResourceTypeConfigMap,
ResourceAnnotations: nil,
WorkloadAnnotations: map[string]string{
"reloader.stakater.com/auto": "true",
"configmaps.exclude.reloader.stakater.com/reload": "other-config,my-config,another-config",
},
PodAnnotations: nil,
},
wantReload: false,
wantAutoReload: false,
description: "ConfigMaps in comma-separated exclude list should not trigger reload",
},
{
name: "explicit reload annotation with auto enabled - should reload",
input: MatchInput{
ResourceName: "external-config",
ResourceNamespace: "default",
ResourceType: ResourceTypeConfigMap,
ResourceAnnotations: nil,
WorkloadAnnotations: map[string]string{
"reloader.stakater.com/auto": "true",
"configmap.reloader.stakater.com/reload": "external-config",
},
PodAnnotations: nil,
},
wantReload: true,
wantAutoReload: false, // Explicit, not auto
description: "BUG FIX: Explicit reload annotation should work even when auto is enabled",
},
{
name: "explicit reload annotation matches pattern - should reload",
input: MatchInput{
ResourceName: "app-config-v2",
ResourceNamespace: "default",
ResourceType: ResourceTypeConfigMap,
ResourceAnnotations: nil,
WorkloadAnnotations: map[string]string{
"configmap.reloader.stakater.com/reload": "app-config-.*",
},
PodAnnotations: nil,
},
wantReload: true,
wantAutoReload: false,
description: "Regex pattern in reload annotation should match",
},
{
name: "explicit reload annotation does not match - should not reload",
input: MatchInput{
ResourceName: "other-config",
ResourceNamespace: "default",
ResourceType: ResourceTypeConfigMap,
ResourceAnnotations: nil,
WorkloadAnnotations: map[string]string{
"configmap.reloader.stakater.com/reload": "app-config",
},
PodAnnotations: nil,
},
wantReload: false,
wantAutoReload: false,
description: "ConfigMaps not in reload list should not trigger reload",
},
{
name: "auto annotation on workload triggers reload",
input: MatchInput{
ResourceName: "my-config",
ResourceNamespace: "default",
ResourceType: ResourceTypeConfigMap,
ResourceAnnotations: nil,
WorkloadAnnotations: map[string]string{"reloader.stakater.com/auto": "true"},
PodAnnotations: nil,
},
wantReload: true,
wantAutoReload: true,
description: "Auto annotation on workload should trigger reload",
},
{
name: "auto annotation on pod template triggers reload",
input: MatchInput{
ResourceName: "my-config",
ResourceNamespace: "default",
ResourceType: ResourceTypeConfigMap,
ResourceAnnotations: nil,
WorkloadAnnotations: nil,
PodAnnotations: map[string]string{"reloader.stakater.com/auto": "true"},
},
wantReload: true,
wantAutoReload: true,
description: "Auto annotation on pod template should trigger reload",
},
{
name: "configmap-specific auto annotation",
input: MatchInput{
ResourceName: "my-config",
ResourceNamespace: "default",
ResourceType: ResourceTypeConfigMap,
ResourceAnnotations: nil,
WorkloadAnnotations: map[string]string{"configmap.reloader.stakater.com/auto": "true"},
PodAnnotations: nil,
},
wantReload: true,
wantAutoReload: true,
description: "ConfigMap-specific auto annotation should trigger reload",
},
{
name: "secret-specific auto annotation for secret",
input: MatchInput{
ResourceName: "my-secret",
ResourceNamespace: "default",
ResourceType: ResourceTypeSecret,
ResourceAnnotations: nil,
WorkloadAnnotations: map[string]string{"secret.reloader.stakater.com/auto": "true"},
PodAnnotations: nil,
},
wantReload: true,
wantAutoReload: true,
description: "Secret-specific auto annotation should trigger reload for secrets",
},
{
name: "configmap-specific auto annotation does not match secret",
input: MatchInput{
ResourceName: "my-secret",
ResourceNamespace: "default",
ResourceType: ResourceTypeSecret,
ResourceAnnotations: nil,
WorkloadAnnotations: map[string]string{"configmap.reloader.stakater.com/auto": "true"},
PodAnnotations: nil,
},
wantReload: false,
wantAutoReload: false,
description: "ConfigMap-specific auto annotation should not match secrets",
},
{
name: "search annotation with matching resource",
input: MatchInput{
ResourceName: "app-config",
ResourceNamespace: "default",
ResourceType: ResourceTypeConfigMap,
ResourceAnnotations: map[string]string{"reloader.stakater.com/match": "true"},
WorkloadAnnotations: map[string]string{"reloader.stakater.com/search": "true"},
PodAnnotations: nil,
},
wantReload: true,
wantAutoReload: true, // Search mode is an auto-discovery mechanism
description: "Search annotation with matching resource should trigger reload",
},
{
name: "search annotation without matching resource",
input: MatchInput{
ResourceName: "app-config",
ResourceNamespace: "default",
ResourceType: ResourceTypeConfigMap,
ResourceAnnotations: nil,
WorkloadAnnotations: map[string]string{"reloader.stakater.com/search": "true"},
PodAnnotations: nil,
},
wantReload: false,
wantAutoReload: false,
description: "Search annotation without matching resource should not trigger reload",
},
{
name: "no annotations does not trigger reload",
input: MatchInput{
ResourceName: "my-config",
ResourceNamespace: "default",
ResourceType: ResourceTypeConfigMap,
ResourceAnnotations: nil,
WorkloadAnnotations: nil,
PodAnnotations: nil,
},
wantReload: false,
wantAutoReload: false,
description: "Without any annotations, should not trigger reload",
},
{
name: "secret reload annotation",
input: MatchInput{
ResourceName: "my-secret",
ResourceNamespace: "default",
ResourceType: ResourceTypeSecret,
ResourceAnnotations: nil,
WorkloadAnnotations: map[string]string{
"secret.reloader.stakater.com/reload": "my-secret",
},
PodAnnotations: nil,
},
wantReload: true,
wantAutoReload: false,
description: "Secret reload annotation should trigger reload",
},
{
name: "secret exclude annotation",
input: MatchInput{
ResourceName: "my-secret",
ResourceNamespace: "default",
ResourceType: ResourceTypeSecret,
ResourceAnnotations: nil,
WorkloadAnnotations: map[string]string{
"reloader.stakater.com/auto": "true",
"secrets.exclude.reloader.stakater.com/reload": "my-secret",
},
PodAnnotations: nil,
},
wantReload: false,
wantAutoReload: false,
description: "Secret exclude annotation should prevent reload",
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
result := matcher.ShouldReload(tt.input)
if result.ShouldReload != tt.wantReload {
t.Errorf("ShouldReload = %v, want %v (%s)", result.ShouldReload, tt.wantReload, tt.description)
}
if result.AutoReload != tt.wantAutoReload {
t.Errorf("AutoReload = %v, want %v (%s)", result.AutoReload, tt.wantAutoReload, tt.description)
}
t.Logf("✓ %s", tt.description)
},
)
}
}
func TestMatcher_ShouldReload_AutoReloadAll(t *testing.T) {
cfg := config.NewDefault()
cfg.AutoReloadAll = true
matcher := NewMatcher(cfg)
tests := []struct {
name string
input MatchInput
wantReload bool
wantAutoReload bool
description string
}{
{
name: "auto-reload-all triggers reload",
input: MatchInput{
ResourceName: "my-config",
ResourceNamespace: "default",
ResourceType: ResourceTypeConfigMap,
ResourceAnnotations: nil,
WorkloadAnnotations: nil,
PodAnnotations: nil,
},
wantReload: true,
wantAutoReload: true,
description: "With auto-reload-all enabled, all ConfigMaps should trigger reload",
},
{
name: "auto-reload-all respects ignore annotation",
input: MatchInput{
ResourceName: "my-config",
ResourceNamespace: "default",
ResourceType: ResourceTypeConfigMap,
ResourceAnnotations: map[string]string{"reloader.stakater.com/ignore": "true"},
WorkloadAnnotations: nil,
PodAnnotations: nil,
},
wantReload: false,
wantAutoReload: false,
description: "Even with auto-reload-all, ignore annotation should be respected",
},
{
name: "auto-reload-all respects exclude annotation",
input: MatchInput{
ResourceName: "my-config",
ResourceNamespace: "default",
ResourceType: ResourceTypeConfigMap,
ResourceAnnotations: nil,
WorkloadAnnotations: map[string]string{
"configmaps.exclude.reloader.stakater.com/reload": "my-config",
},
PodAnnotations: nil,
},
wantReload: false,
wantAutoReload: false,
description: "Even with auto-reload-all, exclude annotation should be respected",
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
result := matcher.ShouldReload(tt.input)
if result.ShouldReload != tt.wantReload {
t.Errorf("ShouldReload = %v, want %v (%s)", result.ShouldReload, tt.wantReload, tt.description)
}
if result.AutoReload != tt.wantAutoReload {
t.Errorf("AutoReload = %v, want %v (%s)", result.AutoReload, tt.wantAutoReload, tt.description)
}
t.Logf("✓ %s", tt.description)
},
)
}
}
// TestMatcher_AutoDoesNotIgnoreExplicit tests the fix for the bug where
// having reloader.stakater.com/auto: "true" would cause explicit reload annotations
// to be ignored due to an early return.
func TestMatcher_AutoDoesNotIgnoreExplicit(t *testing.T) {
cfg := config.NewDefault()
matcher := NewMatcher(cfg)
input := MatchInput{
ResourceName: "external-config", // Not referenced by workload
ResourceNamespace: "default",
ResourceType: ResourceTypeConfigMap,
ResourceAnnotations: nil,
WorkloadAnnotations: map[string]string{
"reloader.stakater.com/auto": "true", // Enables auto-reload
"configmap.reloader.stakater.com/reload": "external-config", // Explicit list
},
PodAnnotations: nil,
}
result := matcher.ShouldReload(input)
if !result.ShouldReload {
t.Errorf("BUG: Explicit reload annotation ignored when auto is enabled")
t.Errorf("Expected ShouldReload=true for explicitly listed ConfigMap, got false")
}
if result.AutoReload {
t.Errorf("Expected AutoReload=false for explicit match, got true")
}
t.Log("✓ Explicit reload annotation works even when auto is enabled")
}
func TestMatcherSecretProviderClassExplicit(t *testing.T) {
cfg := config.NewDefault()
m := NewMatcher(cfg)
res := m.ShouldReload(MatchInput{
ResourceName: "my-spc",
ResourceType: ResourceTypeSecretProviderClass,
WorkloadAnnotations: map[string]string{
"secretproviderclass.reloader.stakater.com/reload": "my-spc",
},
})
if !res.ShouldReload || res.AutoReload {
t.Fatalf("explicit SPC reload: got %+v", res)
}
}
func TestMatcherSecretProviderClassAuto(t *testing.T) {
cfg := config.NewDefault()
m := NewMatcher(cfg)
res := m.ShouldReload(MatchInput{
ResourceName: "my-spc",
ResourceType: ResourceTypeSecretProviderClass,
WorkloadAnnotations: map[string]string{
"secretproviderclass.reloader.stakater.com/auto": "true",
},
})
if !res.ShouldReload || !res.AutoReload {
t.Fatalf("auto SPC reload: got %+v", res)
}
}
func TestMatcherSecretProviderClassExcluded(t *testing.T) {
cfg := config.NewDefault()
m := NewMatcher(cfg)
res := m.ShouldReload(MatchInput{
ResourceName: "my-spc",
ResourceType: ResourceTypeSecretProviderClass,
WorkloadAnnotations: map[string]string{
"secretproviderclass.reloader.stakater.com/auto": "true",
"secretproviderclasses.exclude.reloader.stakater.com/reload": "my-spc",
},
})
if res.ShouldReload {
t.Fatalf("excluded SPC should not reload: got %+v", res)
}
}
// TestMatcher_PrecedenceOrder verifies the correct order of precedence:
// 1. Ignore annotation → skip
// 2. Exclude annotation → skip
// 3. Explicit reload annotation → reload (BUG FIX: before auto!)
// 4. Search/Match → reload
// 5. Auto annotation → reload
// 6. Auto-reload-all → reload
func TestMatcher_PrecedenceOrder(t *testing.T) {
cfg := config.NewDefault()
matcher := NewMatcher(cfg)
t.Run(
"explicit takes precedence over auto", func(t *testing.T) {
input := MatchInput{
ResourceName: "my-config",
ResourceNamespace: "default",
ResourceType: ResourceTypeConfigMap,
WorkloadAnnotations: map[string]string{
"reloader.stakater.com/auto": "true",
"configmap.reloader.stakater.com/reload": "my-config",
},
}
result := matcher.ShouldReload(input)
if result.AutoReload {
t.Error("Expected explicit match (AutoReload=false), got auto match")
}
if !result.ShouldReload {
t.Error("Expected ShouldReload=true")
}
},
)
t.Run(
"ignore takes precedence over explicit", func(t *testing.T) {
input := MatchInput{
ResourceName: "my-config",
ResourceNamespace: "default",
ResourceType: ResourceTypeConfigMap,
ResourceAnnotations: map[string]string{"reloader.stakater.com/ignore": "true"},
WorkloadAnnotations: map[string]string{
"configmap.reloader.stakater.com/reload": "my-config",
},
}
result := matcher.ShouldReload(input)
if result.ShouldReload {
t.Error("Expected ignore to take precedence, but got ShouldReload=true")
}
},
)
t.Run(
"exclude takes precedence over explicit", func(t *testing.T) {
input := MatchInput{
ResourceName: "my-config",
ResourceNamespace: "default",
ResourceType: ResourceTypeConfigMap,
WorkloadAnnotations: map[string]string{
"configmap.reloader.stakater.com/reload": "my-config",
"configmaps.exclude.reloader.stakater.com/reload": "my-config",
},
}
result := matcher.ShouldReload(input)
if result.ShouldReload {
t.Error("Expected exclude to take precedence, but got ShouldReload=true")
}
},
)
}
-128
View File
@@ -1,128 +0,0 @@
package reload
import (
"fmt"
"time"
appsv1 "k8s.io/api/apps/v1"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/internal/pkg/workload"
)
// PauseHandler handles pause deployment logic.
type PauseHandler struct {
cfg *config.Config
}
// NewPauseHandler creates a new PauseHandler.
func NewPauseHandler(cfg *config.Config) *PauseHandler {
return &PauseHandler{cfg: cfg}
}
// ShouldPause checks if a deployment should be paused after reload.
func (h *PauseHandler) ShouldPause(wl workload.Workload) bool {
if wl.Kind() != workload.KindDeployment {
return false
}
annotations := wl.GetAnnotations()
if annotations == nil {
return false
}
pausePeriod := annotations[h.cfg.Annotations.PausePeriod]
return pausePeriod != ""
}
// GetPausePeriod returns the configured pause period for a workload.
func (h *PauseHandler) GetPausePeriod(wl workload.Workload) (time.Duration, error) {
annotations := wl.GetAnnotations()
if annotations == nil {
return 0, fmt.Errorf("no annotations on workload")
}
pausePeriodStr := annotations[h.cfg.Annotations.PausePeriod]
if pausePeriodStr == "" {
return 0, fmt.Errorf("no pause period annotation")
}
return time.ParseDuration(pausePeriodStr)
}
// ApplyPause pauses a deployment and sets the paused-at annotation.
func (h *PauseHandler) ApplyPause(wl workload.Workload) error {
deployWl, ok := wl.(*workload.DeploymentWorkload)
if !ok {
return fmt.Errorf("workload is not a deployment")
}
deploy := deployWl.GetDeployment()
deploy.Spec.Paused = true
if deploy.Annotations == nil {
deploy.Annotations = make(map[string]string)
}
deploy.Annotations[h.cfg.Annotations.PausedAt] = time.Now().UTC().Format(time.RFC3339)
return nil
}
// CheckPauseExpired checks if the pause period has expired for a deployment.
func (h *PauseHandler) CheckPauseExpired(deploy *appsv1.Deployment) (expired bool, remainingTime time.Duration, err error) {
annotations := deploy.GetAnnotations()
if annotations == nil {
return false, 0, fmt.Errorf("no annotations on deployment")
}
pausePeriodStr := annotations[h.cfg.Annotations.PausePeriod]
if pausePeriodStr == "" {
return false, 0, fmt.Errorf("no pause period annotation")
}
pausedAtStr := annotations[h.cfg.Annotations.PausedAt]
if pausedAtStr == "" {
return false, 0, fmt.Errorf("no paused-at annotation")
}
pausePeriod, err := time.ParseDuration(pausePeriodStr)
if err != nil {
return false, 0, fmt.Errorf("invalid pause period %q: %w", pausePeriodStr, err)
}
pausedAt, err := time.Parse(time.RFC3339, pausedAtStr)
if err != nil {
return false, 0, fmt.Errorf("invalid paused-at time %q: %w", pausedAtStr, err)
}
elapsed := time.Since(pausedAt)
if elapsed >= pausePeriod {
return true, 0, nil
}
return false, pausePeriod - elapsed, nil
}
// ClearPause removes the pause from a deployment.
func (h *PauseHandler) ClearPause(deploy *appsv1.Deployment) {
deploy.Spec.Paused = false
delete(deploy.Annotations, h.cfg.Annotations.PausedAt)
}
// IsPausedByReloader checks if a deployment was paused by Reloader.
func (h *PauseHandler) IsPausedByReloader(deploy *appsv1.Deployment) bool {
if !deploy.Spec.Paused {
return false
}
annotations := deploy.GetAnnotations()
if annotations == nil {
return false
}
_, hasPausedAt := annotations[h.cfg.Annotations.PausedAt]
_, hasPausePeriod := annotations[h.cfg.Annotations.PausePeriod]
return hasPausedAt && hasPausePeriod
}
-328
View File
@@ -1,328 +0,0 @@
package reload
import (
"testing"
"time"
appsv1 "k8s.io/api/apps/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/internal/pkg/workload"
)
func TestPauseHandler_ShouldPause(t *testing.T) {
cfg := config.NewDefault()
handler := NewPauseHandler(cfg)
tests := []struct {
name string
workload workload.Workload
want bool
}{
{
name: "deployment with pause period",
workload: workload.NewDeploymentWorkload(&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
cfg.Annotations.PausePeriod: "5m",
},
},
}),
want: true,
},
{
name: "deployment without pause period",
workload: workload.NewDeploymentWorkload(&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{},
}),
want: false,
},
{
name: "daemonset with pause period (ignored)",
workload: workload.NewDaemonSetWorkload(&appsv1.DaemonSet{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
cfg.Annotations.PausePeriod: "5m",
},
},
}),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := handler.ShouldPause(tt.workload)
if got != tt.want {
t.Errorf("ShouldPause() = %v, want %v", got, tt.want)
}
})
}
}
func TestPauseHandler_GetPausePeriod(t *testing.T) {
cfg := config.NewDefault()
handler := NewPauseHandler(cfg)
tests := []struct {
name string
workload workload.Workload
wantPeriod time.Duration
wantErr bool
}{
{
name: "valid pause period",
workload: workload.NewDeploymentWorkload(&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
cfg.Annotations.PausePeriod: "5m",
},
},
}),
wantPeriod: 5 * time.Minute,
wantErr: false,
},
{
name: "invalid pause period",
workload: workload.NewDeploymentWorkload(&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
cfg.Annotations.PausePeriod: "invalid",
},
},
}),
wantErr: true,
},
{
name: "no pause period annotation",
workload: workload.NewDeploymentWorkload(&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{},
}),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := handler.GetPausePeriod(tt.workload)
if (err != nil) != tt.wantErr {
t.Errorf("GetPausePeriod() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && got != tt.wantPeriod {
t.Errorf("GetPausePeriod() = %v, want %v", got, tt.wantPeriod)
}
})
}
}
func TestPauseHandler_ApplyPause(t *testing.T) {
cfg := config.NewDefault()
handler := NewPauseHandler(cfg)
deploy := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: "test-deploy",
},
Spec: appsv1.DeploymentSpec{
Paused: false,
},
}
wl := workload.NewDeploymentWorkload(deploy)
err := handler.ApplyPause(wl)
if err != nil {
t.Fatalf("ApplyPause() error = %v", err)
}
if !deploy.Spec.Paused {
t.Error("Expected deployment to be paused")
}
pausedAt := deploy.Annotations[cfg.Annotations.PausedAt]
if pausedAt == "" {
t.Error("Expected paused-at annotation to be set")
}
// Verify the timestamp is valid
_, err = time.Parse(time.RFC3339, pausedAt)
if err != nil {
t.Errorf("Invalid paused-at timestamp: %v", err)
}
}
func TestPauseHandler_CheckPauseExpired(t *testing.T) {
cfg := config.NewDefault()
handler := NewPauseHandler(cfg)
tests := []struct {
name string
deploy *appsv1.Deployment
wantExpired bool
wantErr bool
}{
{
name: "pause expired",
deploy: &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
cfg.Annotations.PausePeriod: "1ms",
cfg.Annotations.PausedAt: time.Now().Add(-time.Second).UTC().Format(time.RFC3339),
},
},
Spec: appsv1.DeploymentSpec{Paused: true},
},
wantExpired: true,
wantErr: false,
},
{
name: "pause not expired",
deploy: &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
cfg.Annotations.PausePeriod: "1h",
cfg.Annotations.PausedAt: time.Now().UTC().Format(time.RFC3339),
},
},
Spec: appsv1.DeploymentSpec{Paused: true},
},
wantExpired: false,
wantErr: false,
},
{
name: "no paused-at annotation",
deploy: &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
cfg.Annotations.PausePeriod: "5m",
},
},
},
wantErr: true,
},
{
name: "invalid pause period",
deploy: &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
cfg.Annotations.PausePeriod: "invalid",
cfg.Annotations.PausedAt: time.Now().UTC().Format(time.RFC3339),
},
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
expired, _, err := handler.CheckPauseExpired(tt.deploy)
if (err != nil) != tt.wantErr {
t.Errorf("CheckPauseExpired() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && expired != tt.wantExpired {
t.Errorf("CheckPauseExpired() expired = %v, want %v", expired, tt.wantExpired)
}
})
}
}
func TestPauseHandler_ClearPause(t *testing.T) {
cfg := config.NewDefault()
handler := NewPauseHandler(cfg)
deploy := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
cfg.Annotations.PausePeriod: "5m",
cfg.Annotations.PausedAt: time.Now().UTC().Format(time.RFC3339),
},
},
Spec: appsv1.DeploymentSpec{
Paused: true,
},
}
handler.ClearPause(deploy)
if deploy.Spec.Paused {
t.Error("Expected deployment to be unpaused")
}
if _, exists := deploy.Annotations[cfg.Annotations.PausedAt]; exists {
t.Error("Expected paused-at annotation to be removed")
}
// Pause period should be preserved (user's config)
if deploy.Annotations[cfg.Annotations.PausePeriod] != "5m" {
t.Error("Expected pause-period annotation to be preserved")
}
}
func TestPauseHandler_IsPausedByReloader(t *testing.T) {
cfg := config.NewDefault()
handler := NewPauseHandler(cfg)
tests := []struct {
name string
deploy *appsv1.Deployment
want bool
}{
{
name: "paused by reloader",
deploy: &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
cfg.Annotations.PausePeriod: "5m",
cfg.Annotations.PausedAt: time.Now().UTC().Format(time.RFC3339),
},
},
Spec: appsv1.DeploymentSpec{Paused: true},
},
want: true,
},
{
name: "paused but not by reloader (no paused-at)",
deploy: &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
cfg.Annotations.PausePeriod: "5m",
},
},
Spec: appsv1.DeploymentSpec{Paused: true},
},
want: false,
},
{
name: "not paused",
deploy: &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
cfg.Annotations.PausePeriod: "5m",
cfg.Annotations.PausedAt: time.Now().UTC().Format(time.RFC3339),
},
},
Spec: appsv1.DeploymentSpec{Paused: false},
},
want: false,
},
{
name: "no annotations",
deploy: &appsv1.Deployment{
Spec: appsv1.DeploymentSpec{Paused: true},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := handler.IsPausedByReloader(tt.deploy)
if got != tt.want {
t.Errorf("IsPausedByReloader() = %v, want %v", got, tt.want)
}
})
}
}
-179
View File
@@ -1,179 +0,0 @@
package reload
import (
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/event"
"sigs.k8s.io/controller-runtime/pkg/predicate"
csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1"
"github.com/stakater/Reloader/internal/pkg/config"
)
// resourcePredicates returns predicates for filtering resource events.
// The hashFn computes a hash from old and new objects to detect content changes.
func resourcePredicates(cfg *config.Config, hashFn func(old, new client.Object) (string, string, bool)) predicate.Predicate {
return predicate.Funcs{
CreateFunc: func(e event.CreateEvent) bool {
return cfg.ReloadOnCreate || cfg.SyncAfterRestart
},
UpdateFunc: func(e event.UpdateEvent) bool {
oldHash, newHash, ok := hashFn(e.ObjectOld, e.ObjectNew)
if !ok {
return false
}
return oldHash != newHash
},
DeleteFunc: func(e event.DeleteEvent) bool {
return cfg.ReloadOnDelete
},
GenericFunc: func(e event.GenericEvent) bool {
return false
},
}
}
// ConfigMapPredicates returns predicates for filtering ConfigMap events.
func ConfigMapPredicates(cfg *config.Config, hasher *Hasher) predicate.Predicate {
return resourcePredicates(
cfg, func(old, new client.Object) (string, string, bool) {
oldCM, okOld := old.(*corev1.ConfigMap)
newCM, okNew := new.(*corev1.ConfigMap)
if !okOld || !okNew {
return "", "", false
}
return hasher.HashConfigMap(oldCM), hasher.HashConfigMap(newCM), true
},
)
}
// SecretPredicates returns predicates for filtering Secret events.
func SecretPredicates(cfg *config.Config, hasher *Hasher) predicate.Predicate {
return resourcePredicates(
cfg, func(old, new client.Object) (string, string, bool) {
oldSecret, okOld := old.(*corev1.Secret)
newSecret, okNew := new.(*corev1.Secret)
if !okOld || !okNew {
return "", "", false
}
return hasher.HashSecret(oldSecret), hasher.HashSecret(newSecret), true
},
)
}
// NamespaceChecker defines the interface for checking if a namespace is allowed.
type NamespaceChecker interface {
Contains(name string) bool
}
// NamespaceFilterPredicate returns a predicate that filters resources by namespace.
func NamespaceFilterPredicate(cfg *config.Config) predicate.Predicate {
return NamespaceFilterPredicateWithCache(cfg, nil)
}
// NamespaceFilterPredicateWithCache returns a predicate that filters resources by namespace,
// using the provided NamespaceChecker for namespace selector filtering.
func NamespaceFilterPredicateWithCache(cfg *config.Config, nsCache NamespaceChecker) predicate.Predicate {
return predicate.NewPredicateFuncs(
func(obj client.Object) bool {
namespace := obj.GetNamespace()
if cfg.IsNamespaceIgnored(namespace) {
return false
}
if nsCache != nil && !nsCache.Contains(namespace) {
return false
}
return true
},
)
}
// LabelSelectorPredicate returns a predicate that filters resources by labels.
func LabelSelectorPredicate(cfg *config.Config) predicate.Predicate {
if len(cfg.ResourceSelectors) == 0 {
return predicate.NewPredicateFuncs(
func(obj client.Object) bool {
return true
},
)
}
return predicate.NewPredicateFuncs(
func(obj client.Object) bool {
labels := obj.GetLabels()
if labels == nil {
labels = make(map[string]string)
}
for _, selector := range cfg.ResourceSelectors {
if selector.Matches(LabelsSet(labels)) {
return true
}
}
return false
},
)
}
// LabelsSet implements the k8s.io/apimachinery/pkg/labels.Labels interface
// for a map[string]string. This allows using label maps with label selectors.
type LabelsSet map[string]string
// Has returns whether the provided label key exists in the set.
func (ls LabelsSet) Has(key string) bool {
_, ok := ls[key]
return ok
}
// Get returns the value for the provided label key.
func (ls LabelsSet) Get(key string) string {
return ls[key]
}
// Lookup returns the value for the provided label key and whether it exists.
func (ls LabelsSet) Lookup(key string) (string, bool) {
value, ok := ls[key]
return value, ok
}
// IgnoreAnnotationPredicate returns a predicate that filters out resources with the ignore annotation.
func IgnoreAnnotationPredicate(cfg *config.Config) predicate.Predicate {
return predicate.NewPredicateFuncs(
func(obj client.Object) bool {
annotations := obj.GetAnnotations()
if annotations == nil {
return true
}
return annotations[cfg.Annotations.Ignore] != "true"
},
)
}
// CombinedPredicates combines multiple predicates with AND logic.
func CombinedPredicates(predicates ...predicate.Predicate) predicate.Predicate {
return predicate.And(predicates...)
}
// SecretProviderClassPodStatusPredicates filters SecretProviderClassPodStatus events.
// Create and Delete are ignored (matching master); Update passes only when the
// hashed status (object IDs/versions + SPC name) changes.
func SecretProviderClassPodStatusPredicates(cfg *config.Config, hasher *Hasher) predicate.Predicate {
return predicate.Funcs{
CreateFunc: func(e event.CreateEvent) bool { return false },
DeleteFunc: func(e event.DeleteEvent) bool { return false },
GenericFunc: func(e event.GenericEvent) bool { return false },
UpdateFunc: func(e event.UpdateEvent) bool {
oldObj, okOld := e.ObjectOld.(*csiv1.SecretProviderClassPodStatus)
newObj, okNew := e.ObjectNew.(*csiv1.SecretProviderClassPodStatus)
if !okOld || !okNew {
return false
}
return hasher.HashSecretProviderClass(oldObj.Status) != hasher.HashSecretProviderClass(newObj.Status)
},
}
}
-986
View File
@@ -1,986 +0,0 @@
package reload
import (
"testing"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
"sigs.k8s.io/controller-runtime/pkg/event"
csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1"
"github.com/stakater/Reloader/internal/pkg/config"
)
func TestNamespaceFilterPredicate_Create(t *testing.T) {
tests := []struct {
name string
ignoredNamespaces []string
eventNamespace string
wantAllow bool
}{
{
name: "allow non-ignored namespace",
ignoredNamespaces: []string{"kube-system"},
eventNamespace: "default",
wantAllow: true,
},
{
name: "block ignored namespace",
ignoredNamespaces: []string{"kube-system"},
eventNamespace: "kube-system",
wantAllow: false,
},
{
name: "allow when no namespaces ignored",
ignoredNamespaces: []string{},
eventNamespace: "kube-system",
wantAllow: true,
},
{
name: "block multiple ignored namespaces",
ignoredNamespaces: []string{"kube-system", "kube-public", "test-ns"},
eventNamespace: "test-ns",
wantAllow: false,
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
cfg := config.NewDefault()
cfg.IgnoredNamespaces = tt.ignoredNamespaces
predicate := NamespaceFilterPredicate(cfg)
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: tt.eventNamespace,
},
}
e := event.CreateEvent{Object: cm}
got := predicate.Create(e)
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v", got, tt.wantAllow)
}
},
)
}
}
func TestNamespaceFilterPredicate_Update(t *testing.T) {
cfg := config.NewDefault()
cfg.IgnoredNamespaces = []string{"kube-system"}
predicate := NamespaceFilterPredicate(cfg)
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
},
}
e := event.UpdateEvent{ObjectNew: cm}
if !predicate.Update(e) {
t.Error("Update() should allow non-ignored namespace")
}
cm.Namespace = "kube-system"
e = event.UpdateEvent{ObjectNew: cm}
if predicate.Update(e) {
t.Error("Update() should block ignored namespace")
}
}
func TestNamespaceFilterPredicate_Delete(t *testing.T) {
cfg := config.NewDefault()
cfg.IgnoredNamespaces = []string{"kube-system"}
predicate := NamespaceFilterPredicate(cfg)
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
},
}
e := event.DeleteEvent{Object: cm}
if !predicate.Delete(e) {
t.Error("Delete() should allow non-ignored namespace")
}
}
func TestNamespaceFilterPredicate_Generic(t *testing.T) {
cfg := config.NewDefault()
cfg.IgnoredNamespaces = []string{"kube-system"}
predicate := NamespaceFilterPredicate(cfg)
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
},
}
e := event.GenericEvent{Object: cm}
if !predicate.Generic(e) {
t.Error("Generic() should allow non-ignored namespace")
}
}
func TestLabelSelectorPredicate_Create(t *testing.T) {
tests := []struct {
name string
selector string
objectLabels map[string]string
wantAllow bool
}{
{
name: "match single label",
selector: "app=reloader",
objectLabels: map[string]string{"app": "reloader"},
wantAllow: true,
},
{
name: "no match single label",
selector: "app=reloader",
objectLabels: map[string]string{"app": "other"},
wantAllow: false,
},
{
name: "match multiple labels",
selector: "app=reloader,env=prod",
objectLabels: map[string]string{"app": "reloader", "env": "prod", "extra": "value"},
wantAllow: true,
},
{
name: "partial match fails",
selector: "app=reloader,env=prod",
objectLabels: map[string]string{"app": "reloader"},
wantAllow: false,
},
{
name: "empty labels no match",
selector: "app=reloader",
objectLabels: map[string]string{},
wantAllow: false,
},
{
name: "nil labels no match",
selector: "app=reloader",
objectLabels: nil,
wantAllow: false,
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
cfg := config.NewDefault()
selector, err := labels.Parse(tt.selector)
if err != nil {
t.Fatalf("Failed to parse selector: %v", err)
}
cfg.ResourceSelectors = []labels.Selector{selector}
predicate := LabelSelectorPredicate(cfg)
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
Labels: tt.objectLabels,
},
}
e := event.CreateEvent{Object: cm}
got := predicate.Create(e)
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v", got, tt.wantAllow)
}
},
)
}
}
func TestLabelSelectorPredicate_NoSelectors(t *testing.T) {
cfg := config.NewDefault()
predicate := LabelSelectorPredicate(cfg)
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
Labels: map[string]string{"any": "label"},
},
}
e := event.CreateEvent{Object: cm}
if !predicate.Create(e) {
t.Error("Create() should allow all when no selectors configured")
}
}
func TestLabelSelectorPredicate_MultipleSelectors(t *testing.T) {
cfg := config.NewDefault()
selector1, _ := labels.Parse("app=reloader")
selector2, _ := labels.Parse("type=config")
cfg.ResourceSelectors = []labels.Selector{selector1, selector2}
predicate := LabelSelectorPredicate(cfg)
tests := []struct {
name string
labels map[string]string
wantAllow bool
}{
{
name: "matches first selector",
labels: map[string]string{"app": "reloader"},
wantAllow: true,
},
{
name: "matches second selector",
labels: map[string]string{"type": "config"},
wantAllow: true,
},
{
name: "matches both selectors",
labels: map[string]string{"app": "reloader", "type": "config"},
wantAllow: true,
},
{
name: "matches neither selector",
labels: map[string]string{"other": "value"},
wantAllow: false,
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
Labels: tt.labels,
},
}
e := event.CreateEvent{Object: cm}
got := predicate.Create(e)
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v", got, tt.wantAllow)
}
},
)
}
}
func TestLabelSelectorPredicate_Update(t *testing.T) {
cfg := config.NewDefault()
selector, _ := labels.Parse("app=reloader")
cfg.ResourceSelectors = []labels.Selector{selector}
predicate := LabelSelectorPredicate(cfg)
cmMatching := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
Labels: map[string]string{"app": "reloader"},
},
}
cmNotMatching := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
Labels: map[string]string{"app": "other"},
},
}
e := event.UpdateEvent{ObjectNew: cmMatching}
if !predicate.Update(e) {
t.Error("Update() should allow matching labels")
}
e = event.UpdateEvent{ObjectNew: cmNotMatching}
if predicate.Update(e) {
t.Error("Update() should block non-matching labels")
}
}
func TestLabelSelectorPredicate_Delete(t *testing.T) {
cfg := config.NewDefault()
selector, _ := labels.Parse("app=reloader")
cfg.ResourceSelectors = []labels.Selector{selector}
predicate := LabelSelectorPredicate(cfg)
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
Labels: map[string]string{"app": "reloader"},
},
}
e := event.DeleteEvent{Object: cm}
if !predicate.Delete(e) {
t.Error("Delete() should allow matching labels")
}
}
func TestLabelSelectorPredicate_Generic(t *testing.T) {
cfg := config.NewDefault()
selector, _ := labels.Parse("app=reloader")
cfg.ResourceSelectors = []labels.Selector{selector}
predicate := LabelSelectorPredicate(cfg)
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
Labels: map[string]string{"app": "reloader"},
},
}
e := event.GenericEvent{Object: cm}
if !predicate.Generic(e) {
t.Error("Generic() should allow matching labels")
}
}
func TestCombinedFiltering(t *testing.T) {
cfg := config.NewDefault()
cfg.IgnoredNamespaces = []string{"kube-system"}
selector, _ := labels.Parse("managed=true")
cfg.ResourceSelectors = []labels.Selector{selector}
nsPredicate := NamespaceFilterPredicate(cfg)
labelPredicate := LabelSelectorPredicate(cfg)
tests := []struct {
name string
namespace string
labels map[string]string
wantNSAllow bool
wantLabelAllow bool
}{
{
name: "allowed namespace and matching labels",
namespace: "default",
labels: map[string]string{"managed": "true"},
wantNSAllow: true,
wantLabelAllow: true,
},
{
name: "allowed namespace but non-matching labels",
namespace: "default",
labels: map[string]string{"managed": "false"},
wantNSAllow: true,
wantLabelAllow: false,
},
{
name: "ignored namespace with matching labels",
namespace: "kube-system",
labels: map[string]string{"managed": "true"},
wantNSAllow: false,
wantLabelAllow: true,
},
{
name: "ignored namespace and non-matching labels",
namespace: "kube-system",
labels: map[string]string{"managed": "false"},
wantNSAllow: false,
wantLabelAllow: false,
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: tt.namespace,
Labels: tt.labels,
},
}
e := event.CreateEvent{Object: cm}
gotNS := nsPredicate.Create(e)
if gotNS != tt.wantNSAllow {
t.Errorf("Namespace predicate Create() = %v, want %v", gotNS, tt.wantNSAllow)
}
gotLabel := labelPredicate.Create(e)
if gotLabel != tt.wantLabelAllow {
t.Errorf("Label predicate Create() = %v, want %v", gotLabel, tt.wantLabelAllow)
}
combinedAllow := gotNS && gotLabel
expectedCombined := tt.wantNSAllow && tt.wantLabelAllow
if combinedAllow != expectedCombined {
t.Errorf("Combined allow = %v, want %v", combinedAllow, expectedCombined)
}
},
)
}
}
func TestFilteringWithSecrets(t *testing.T) {
cfg := config.NewDefault()
cfg.IgnoredNamespaces = []string{"kube-system"}
nsPredicate := NamespaceFilterPredicate(cfg)
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "test-secret",
Namespace: "default",
},
}
e := event.CreateEvent{Object: secret}
if !nsPredicate.Create(e) {
t.Error("Should allow secret in non-ignored namespace")
}
secret.Namespace = "kube-system"
e = event.CreateEvent{Object: secret}
if nsPredicate.Create(e) {
t.Error("Should block secret in ignored namespace")
}
}
func TestExistsLabelSelector(t *testing.T) {
cfg := config.NewDefault()
selector, _ := labels.Parse("managed")
cfg.ResourceSelectors = []labels.Selector{selector}
predicate := LabelSelectorPredicate(cfg)
tests := []struct {
name string
labels map[string]string
wantAllow bool
}{
{
name: "label exists with value true",
labels: map[string]string{"managed": "true"},
wantAllow: true,
},
{
name: "label exists with value false",
labels: map[string]string{"managed": "false"},
wantAllow: true,
},
{
name: "label exists with empty value",
labels: map[string]string{"managed": ""},
wantAllow: true,
},
{
name: "label does not exist",
labels: map[string]string{"other": "value"},
wantAllow: false,
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
Labels: tt.labels,
},
}
e := event.CreateEvent{Object: cm}
got := predicate.Create(e)
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v", got, tt.wantAllow)
}
},
)
}
}
// mockNamespaceChecker implements NamespaceChecker for testing.
type mockNamespaceChecker struct {
allowed map[string]bool
}
func (m *mockNamespaceChecker) Contains(name string) bool {
return m.allowed[name]
}
func TestNamespaceFilterPredicateWithCache(t *testing.T) {
tests := []struct {
name string
ignoredNamespaces []string
cacheAllowed map[string]bool
eventNamespace string
wantAllow bool
}{
{
name: "allowed by cache and not ignored",
ignoredNamespaces: []string{"kube-system"},
cacheAllowed: map[string]bool{"production": true},
eventNamespace: "production",
wantAllow: true,
},
{
name: "blocked by cache",
ignoredNamespaces: []string{},
cacheAllowed: map[string]bool{"production": true},
eventNamespace: "staging",
wantAllow: false,
},
{
name: "blocked by ignore list even if in cache",
ignoredNamespaces: []string{"kube-system"},
cacheAllowed: map[string]bool{"kube-system": true},
eventNamespace: "kube-system",
wantAllow: false,
},
{
name: "ignore list checked before cache",
ignoredNamespaces: []string{"blocked-ns"},
cacheAllowed: map[string]bool{"blocked-ns": true},
eventNamespace: "blocked-ns",
wantAllow: false,
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
cfg := config.NewDefault()
cfg.IgnoredNamespaces = tt.ignoredNamespaces
cache := &mockNamespaceChecker{allowed: tt.cacheAllowed}
predicate := NamespaceFilterPredicateWithCache(cfg, cache)
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: tt.eventNamespace,
},
}
e := event.CreateEvent{Object: cm}
got := predicate.Create(e)
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v", got, tt.wantAllow)
}
},
)
}
}
func TestNamespaceFilterPredicateWithCache_NilCache(t *testing.T) {
cfg := config.NewDefault()
cfg.IgnoredNamespaces = []string{"kube-system"}
predicate := NamespaceFilterPredicateWithCache(cfg, nil)
tests := []struct {
namespace string
wantAllow bool
}{
{"default", true},
{"production", true},
{"kube-system", false}, // Should still respect ignore list
}
for _, tt := range tests {
t.Run(
tt.namespace, func(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: tt.namespace,
},
}
e := event.CreateEvent{Object: cm}
got := predicate.Create(e)
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v for namespace %s", got, tt.wantAllow, tt.namespace)
}
},
)
}
}
func TestIgnoreAnnotationPredicate_Create(t *testing.T) {
cfg := config.NewDefault()
predicate := IgnoreAnnotationPredicate(cfg)
tests := []struct {
name string
annotations map[string]string
wantAllow bool
}{
{
name: "no annotations",
annotations: nil,
wantAllow: true,
},
{
name: "empty annotations",
annotations: map[string]string{},
wantAllow: true,
},
{
name: "other annotations only",
annotations: map[string]string{"other": "value"},
wantAllow: true,
},
{
name: "ignore annotation true",
annotations: map[string]string{cfg.Annotations.Ignore: "true"},
wantAllow: false,
},
{
name: "ignore annotation false",
annotations: map[string]string{cfg.Annotations.Ignore: "false"},
wantAllow: true,
},
{
name: "ignore annotation with other value",
annotations: map[string]string{cfg.Annotations.Ignore: "yes"},
wantAllow: true,
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
Annotations: tt.annotations,
},
}
e := event.CreateEvent{Object: cm}
got := predicate.Create(e)
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v", got, tt.wantAllow)
}
},
)
}
}
func TestIgnoreAnnotationPredicate_AllEventTypes(t *testing.T) {
cfg := config.NewDefault()
predicate := IgnoreAnnotationPredicate(cfg)
ignoredCM := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "ignored-cm",
Namespace: "default",
Annotations: map[string]string{cfg.Annotations.Ignore: "true"},
},
}
allowedCM := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "allowed-cm",
Namespace: "default",
},
}
if predicate.Update(event.UpdateEvent{ObjectNew: ignoredCM}) {
t.Error("Update() should block ignored resource")
}
if !predicate.Update(event.UpdateEvent{ObjectNew: allowedCM}) {
t.Error("Update() should allow non-ignored resource")
}
if predicate.Delete(event.DeleteEvent{Object: ignoredCM}) {
t.Error("Delete() should block ignored resource")
}
if !predicate.Delete(event.DeleteEvent{Object: allowedCM}) {
t.Error("Delete() should allow non-ignored resource")
}
if predicate.Generic(event.GenericEvent{Object: ignoredCM}) {
t.Error("Generic() should block ignored resource")
}
if !predicate.Generic(event.GenericEvent{Object: allowedCM}) {
t.Error("Generic() should allow non-ignored resource")
}
}
func TestCombinedPredicates(t *testing.T) {
cfg := config.NewDefault()
cfg.IgnoredNamespaces = []string{"kube-system"}
nsPredicate := NamespaceFilterPredicate(cfg)
ignorePredicate := IgnoreAnnotationPredicate(cfg)
combined := CombinedPredicates(nsPredicate, ignorePredicate)
tests := []struct {
name string
namespace string
annotations map[string]string
wantAllow bool
}{
{
name: "both predicates pass",
namespace: "default",
annotations: nil,
wantAllow: true,
},
{
name: "namespace predicate fails",
namespace: "kube-system",
annotations: nil,
wantAllow: false,
},
{
name: "ignore predicate fails",
namespace: "default",
annotations: map[string]string{cfg.Annotations.Ignore: "true"},
wantAllow: false,
},
{
name: "both predicates fail",
namespace: "kube-system",
annotations: map[string]string{cfg.Annotations.Ignore: "true"},
wantAllow: false,
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: tt.namespace,
Annotations: tt.annotations,
},
}
e := event.CreateEvent{Object: cm}
got := combined.Create(e)
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v", got, tt.wantAllow)
}
},
)
}
}
func TestConfigMapPredicates_Update(t *testing.T) {
cfg := config.NewDefault()
hasher := NewHasher()
predicate := ConfigMapPredicates(cfg, hasher)
oldCM := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
Data: map[string]string{"key": "value1"},
}
newCMSameContent := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
Data: map[string]string{"key": "value1"},
}
newCMDifferentContent := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
Data: map[string]string{"key": "value2"},
}
e := event.UpdateEvent{ObjectOld: oldCM, ObjectNew: newCMSameContent}
if predicate.Update(e) {
t.Error("Update() should return false when content is the same")
}
e = event.UpdateEvent{ObjectOld: oldCM, ObjectNew: newCMDifferentContent}
if !predicate.Update(e) {
t.Error("Update() should return true when content changed")
}
}
func TestConfigMapPredicates_InvalidTypes(t *testing.T) {
cfg := config.NewDefault()
hasher := NewHasher()
predicate := ConfigMapPredicates(cfg, hasher)
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
}
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
}
e := event.UpdateEvent{ObjectOld: secret, ObjectNew: cm}
if predicate.Update(e) {
t.Error("Update() should return false for mismatched types")
}
e = event.UpdateEvent{ObjectOld: secret, ObjectNew: secret}
if predicate.Update(e) {
t.Error("Update() should return false for non-ConfigMap types")
}
}
func TestConfigMapPredicates_CreateDeleteGeneric(t *testing.T) {
cfg := config.NewDefault()
cfg.ReloadOnCreate = true
cfg.ReloadOnDelete = true
hasher := NewHasher()
predicate := ConfigMapPredicates(cfg, hasher)
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
}
if !predicate.Create(event.CreateEvent{Object: cm}) {
t.Error("Create() should return true when ReloadOnCreate is true")
}
if !predicate.Delete(event.DeleteEvent{Object: cm}) {
t.Error("Delete() should return true when ReloadOnDelete is true")
}
if predicate.Generic(event.GenericEvent{Object: cm}) {
t.Error("Generic() should always return false")
}
}
func TestSecretPredicates_Update(t *testing.T) {
cfg := config.NewDefault()
hasher := NewHasher()
predicate := SecretPredicates(cfg, hasher)
oldSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
Data: map[string][]byte{"key": []byte("value1")},
}
newSecretSameContent := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
Data: map[string][]byte{"key": []byte("value1")},
}
newSecretDifferentContent := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
Data: map[string][]byte{"key": []byte("value2")},
}
e := event.UpdateEvent{ObjectOld: oldSecret, ObjectNew: newSecretSameContent}
if predicate.Update(e) {
t.Error("Update() should return false when content is the same")
}
e = event.UpdateEvent{ObjectOld: oldSecret, ObjectNew: newSecretDifferentContent}
if !predicate.Update(e) {
t.Error("Update() should return true when content changed")
}
}
func TestSecretPredicates_InvalidTypes(t *testing.T) {
cfg := config.NewDefault()
hasher := NewHasher()
predicate := SecretPredicates(cfg, hasher)
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
}
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
}
e := event.UpdateEvent{ObjectOld: cm, ObjectNew: secret}
if predicate.Update(e) {
t.Error("Update() should return false for mismatched types")
}
e = event.UpdateEvent{ObjectOld: cm, ObjectNew: cm}
if predicate.Update(e) {
t.Error("Update() should return false for non-Secret types")
}
}
func TestLabelsSet(t *testing.T) {
ls := LabelsSet{"app": "test", "env": "prod"}
if !ls.Has("app") {
t.Error("Has(app) should return true")
}
if ls.Has("nonexistent") {
t.Error("Has(nonexistent) should return false")
}
if ls.Get("app") != "test" {
t.Errorf("Get(app) = %v, want test", ls.Get("app"))
}
if ls.Get("env") != "prod" {
t.Errorf("Get(env) = %v, want prod", ls.Get("env"))
}
if ls.Get("nonexistent") != "" {
t.Errorf("Get(nonexistent) = %v, want empty string", ls.Get("nonexistent"))
}
}
func TestSecretProviderClassPodStatusPredicates(t *testing.T) {
cfg := config.NewDefault()
p := SecretProviderClassPodStatusPredicates(cfg, NewHasher())
oldObj := &csiv1.SecretProviderClassPodStatus{
Status: csiv1.SecretProviderClassPodStatusStatus{
SecretProviderClassName: "spc",
Objects: []csiv1.SecretProviderClassObject{{ID: "a", Version: "1"}},
},
}
newObjChanged := &csiv1.SecretProviderClassPodStatus{
Status: csiv1.SecretProviderClassPodStatusStatus{
SecretProviderClassName: "spc",
Objects: []csiv1.SecretProviderClassObject{{ID: "a", Version: "2"}},
},
}
newObjSame := oldObj.DeepCopy()
// Create and Delete are always ignored for SPCPS.
if p.Create(event.CreateEvent{Object: oldObj}) {
t.Fatal("CreateFunc should return false")
}
if p.Delete(event.DeleteEvent{Object: oldObj}) {
t.Fatal("DeleteFunc should return false")
}
// Update only when the status hash changes.
if !p.Update(event.UpdateEvent{ObjectOld: oldObj, ObjectNew: newObjChanged}) {
t.Fatal("UpdateFunc should return true on changed status")
}
if p.Update(event.UpdateEvent{ObjectOld: oldObj, ObjectNew: newObjSame}) {
t.Fatal("UpdateFunc should return false on unchanged status")
}
// A metadata/label-only change (same Status) must NOT trigger a reload,
// since the predicate hashes only the status. (Master tested this via
// UpdateSecretProviderClassPodStatusLabels.)
labelOnly := oldObj.DeepCopy()
labelOnly.Labels = map[string]string{"unrelated": "changed"}
labelOnly.Annotations = map[string]string{"note": "touched"}
if p.Update(event.UpdateEvent{ObjectOld: oldObj, ObjectNew: labelOnly}) {
t.Fatal("UpdateFunc should return false on a label/metadata-only change")
}
// Type-assertion failure (wrong object type) must be rejected, not panic.
if p.Update(event.UpdateEvent{ObjectOld: &corev1.ConfigMap{}, ObjectNew: &corev1.ConfigMap{}}) {
t.Fatal("UpdateFunc should return false when objects are not SPCPS")
}
}
-27
View File
@@ -1,27 +0,0 @@
package reload
// ResourceType represents the type of Kubernetes resource.
type ResourceType string
const (
// ResourceTypeConfigMap represents a ConfigMap resource.
ResourceTypeConfigMap ResourceType = "configmap"
// ResourceTypeSecret represents a Secret resource.
ResourceTypeSecret ResourceType = "secret"
// ResourceTypeSecretProviderClass represents a CSI SecretProviderClass resource.
ResourceTypeSecretProviderClass ResourceType = "secretproviderclass"
)
// Kind returns the capitalized Kubernetes Kind (e.g., "ConfigMap", "Secret").
func (r ResourceType) Kind() string {
switch r {
case ResourceTypeConfigMap:
return "ConfigMap"
case ResourceTypeSecret:
return "Secret"
case ResourceTypeSecretProviderClass:
return "SecretProviderClass"
default:
return string(r)
}
}
-37
View File
@@ -1,37 +0,0 @@
package reload
import (
"testing"
)
func TestResourceType_Kind(t *testing.T) {
tests := []struct {
resourceType ResourceType
want string
}{
{ResourceTypeConfigMap, "ConfigMap"},
{ResourceTypeSecret, "Secret"},
{ResourceType("unknown"), "unknown"},
{ResourceType("custom"), "custom"},
}
for _, tt := range tests {
t.Run(
string(tt.resourceType), func(t *testing.T) {
got := tt.resourceType.Kind()
if got != tt.want {
t.Errorf("ResourceType(%q).Kind() = %v, want %v", tt.resourceType, got, tt.want)
}
},
)
}
}
func TestResourceTypeSecretProviderClassKind(t *testing.T) {
if got := ResourceTypeSecretProviderClass.Kind(); got != "SecretProviderClass" {
t.Fatalf("Kind() = %q, want SecretProviderClass", got)
}
if string(ResourceTypeSecretProviderClass) != "secretproviderclass" {
t.Fatalf("value = %q, want secretproviderclass", ResourceTypeSecretProviderClass)
}
}
-329
View File
@@ -1,329 +0,0 @@
package reload
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/go-logr/logr"
corev1 "k8s.io/api/core/v1"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/internal/pkg/workload"
)
// Service orchestrates the reload logic for ConfigMaps and Secrets.
type Service struct {
cfg *config.Config
log logr.Logger
hasher *Hasher
matcher *Matcher
strategy Strategy
}
// NewService creates a new reload Service with the given configuration.
func NewService(cfg *config.Config, log logr.Logger) *Service {
return &Service{
cfg: cfg,
log: log,
hasher: NewHasher(),
matcher: NewMatcher(cfg),
strategy: NewStrategy(cfg),
}
}
// Process evaluates all workloads to determine which should be reloaded.
func (s *Service) Process(change ResourceChange, workloads []workload.Workload) []ReloadDecision {
if change.IsNil() {
return nil
}
if !s.shouldProcessEvent(change.GetEventType()) {
return nil
}
hash := change.ComputeHash(s.hasher)
if change.GetEventType() == EventTypeDelete {
hash = s.hasher.EmptyHash()
}
return s.processResource(
change.GetName(),
change.GetNamespace(),
change.GetAnnotations(),
change.GetResourceType(),
hash,
workloads,
)
}
func (s *Service) processResource(
resourceName string,
resourceNamespace string,
resourceAnnotations map[string]string,
resourceType ResourceType,
hash string,
workloads []workload.Workload,
) []ReloadDecision {
var decisions []ReloadDecision
for _, wl := range workloads {
if wl.GetNamespace() != resourceNamespace {
continue
}
if s.cfg.IsWorkloadIgnored(string(wl.Kind())) {
continue
}
var usesResource bool
switch resourceType {
case ResourceTypeConfigMap:
usesResource = wl.UsesConfigMap(resourceName)
case ResourceTypeSecret:
usesResource = wl.UsesSecret(resourceName)
case ResourceTypeSecretProviderClass:
// Annotation-only matching (parity with master): the workload's
// annotations alone decide the reload; no volume-uses scan.
usesResource = true
}
input := MatchInput{
ResourceName: resourceName,
ResourceNamespace: resourceNamespace,
ResourceType: resourceType,
ResourceAnnotations: resourceAnnotations,
WorkloadAnnotations: wl.GetAnnotations(),
PodAnnotations: wl.GetPodTemplateAnnotations(),
}
matchResult := s.matcher.ShouldReload(input)
shouldReload := matchResult.ShouldReload
if matchResult.AutoReload && !usesResource {
shouldReload = false
}
decisions = append(
decisions, ReloadDecision{
Workload: wl,
ShouldReload: shouldReload,
AutoReload: matchResult.AutoReload,
Reason: matchResult.Reason,
Hash: hash,
},
)
}
return decisions
}
func (s *Service) shouldProcessEvent(eventType EventType) bool {
switch eventType {
case EventTypeCreate:
return s.cfg.ReloadOnCreate
case EventTypeDelete:
return s.cfg.ReloadOnDelete
case EventTypeUpdate:
return true
default:
return false
}
}
// ApplyReload applies the reload strategy to a workload.
func (s *Service) ApplyReload(
ctx context.Context,
wl workload.Workload,
resourceName string,
resourceType ResourceType,
namespace string,
hash string,
autoReload bool,
) (bool, error) {
container := s.findTargetContainer(wl, resourceName, resourceType, autoReload)
input := StrategyInput{
ResourceName: resourceName,
ResourceType: resourceType,
Namespace: namespace,
Hash: hash,
Container: container,
PodAnnotations: wl.GetPodTemplateAnnotations(),
AutoReload: autoReload,
}
updated, err := s.strategy.Apply(input)
if err != nil {
return false, err
}
if updated {
// Attribution annotation is informational; log errors but don't fail reloads
if err := s.setAttributionAnnotation(wl, resourceName, resourceType, namespace, hash, container); err != nil {
s.log.V(1).Info("failed to set attribution annotation", "error", err, "workload", wl.GetName())
}
}
return updated, nil
}
func (s *Service) setAttributionAnnotation(
wl workload.Workload,
resourceName string,
resourceType ResourceType,
namespace string,
hash string,
container *corev1.Container,
) error {
containerName := ""
if container != nil {
containerName = container.Name
}
source := ReloadSource{
Kind: string(resourceType),
Name: resourceName,
Namespace: namespace,
Hash: hash,
Containers: []string{containerName},
ReloadedAt: time.Now().UTC(),
}
sourceJSON, err := json.Marshal(source)
if err != nil {
return fmt.Errorf("failed to marshal reload source: %w", err)
}
wl.SetPodTemplateAnnotation(s.cfg.Annotations.LastReloadedFrom, string(sourceJSON))
return nil
}
func (s *Service) findTargetContainer(
wl workload.Workload,
resourceName string,
resourceType ResourceType,
autoReload bool,
) *corev1.Container {
containers := wl.GetContainers()
if len(containers) == 0 {
return nil
}
if !autoReload {
return &containers[0]
}
volumes := wl.GetVolumes()
initContainers := wl.GetInitContainers()
volumeName := s.findVolumeUsingResource(volumes, resourceName, resourceType)
if volumeName != "" {
container := s.findContainerWithVolumeMount(containers, volumeName)
if container != nil {
return container
}
container = s.findContainerWithVolumeMount(initContainers, volumeName)
if container != nil {
return &containers[0]
}
}
container := s.findContainerWithEnvRef(containers, resourceName, resourceType)
if container != nil {
return container
}
container = s.findContainerWithEnvRef(initContainers, resourceName, resourceType)
if container != nil {
return &containers[0]
}
return &containers[0]
}
func (s *Service) findVolumeUsingResource(volumes []corev1.Volume, resourceName string, resourceType ResourceType) string {
for _, vol := range volumes {
switch resourceType {
case ResourceTypeConfigMap:
if vol.ConfigMap != nil && vol.ConfigMap.Name == resourceName {
return vol.Name
}
if vol.Projected != nil {
for _, src := range vol.Projected.Sources {
if src.ConfigMap != nil && src.ConfigMap.Name == resourceName {
return vol.Name
}
}
}
case ResourceTypeSecret:
if vol.Secret != nil && vol.Secret.SecretName == resourceName {
return vol.Name
}
if vol.Projected != nil {
for _, src := range vol.Projected.Sources {
if src.Secret != nil && src.Secret.Name == resourceName {
return vol.Name
}
}
}
case ResourceTypeSecretProviderClass:
// Match the CSI volume that references this SPC.
if vol.CSI != nil && vol.CSI.VolumeAttributes["secretProviderClass"] == resourceName {
return vol.Name
}
}
}
return ""
}
func (s *Service) findContainerWithVolumeMount(containers []corev1.Container, volumeName string) *corev1.Container {
for i := range containers {
for _, mount := range containers[i].VolumeMounts {
if mount.Name == volumeName {
return &containers[i]
}
}
}
return nil
}
func (s *Service) findContainerWithEnvRef(containers []corev1.Container, resourceName string, resourceType ResourceType) *corev1.Container {
for i := range containers {
for _, env := range containers[i].Env {
if env.ValueFrom == nil {
continue
}
switch resourceType {
case ResourceTypeConfigMap:
if env.ValueFrom.ConfigMapKeyRef != nil && env.ValueFrom.ConfigMapKeyRef.Name == resourceName {
return &containers[i]
}
case ResourceTypeSecret:
if env.ValueFrom.SecretKeyRef != nil && env.ValueFrom.SecretKeyRef.Name == resourceName {
return &containers[i]
}
}
}
for _, envFrom := range containers[i].EnvFrom {
switch resourceType {
case ResourceTypeConfigMap:
if envFrom.ConfigMapRef != nil && envFrom.ConfigMapRef.Name == resourceName {
return &containers[i]
}
case ResourceTypeSecret:
if envFrom.SecretRef != nil && envFrom.SecretRef.Name == resourceName {
return &containers[i]
}
}
}
}
return nil
}
// Hasher returns the hasher used by this service.
func (s *Service) Hasher() *Hasher {
return s.hasher
}
File diff suppressed because it is too large Load Diff
-205
View File
@@ -1,205 +0,0 @@
package reload
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"time"
corev1 "k8s.io/api/core/v1"
"github.com/stakater/Reloader/internal/pkg/config"
)
const (
// EnvVarPrefix is the prefix for environment variables added by Reloader.
EnvVarPrefix = "STAKATER_"
// ConfigmapEnvVarPostfix is the postfix for ConfigMap environment variables.
ConfigmapEnvVarPostfix = "CONFIGMAP"
// SecretEnvVarPostfix is the postfix for Secret environment variables.
SecretEnvVarPostfix = "SECRET"
// SecretProviderClassEnvVarPostfix is the postfix for SecretProviderClass environment variables.
SecretProviderClassEnvVarPostfix = "SECRETPROVIDERCLASS"
)
// Strategy defines how workload restarts are triggered.
type Strategy interface {
Apply(input StrategyInput) (bool, error)
Name() string
}
// StrategyInput contains the information needed to apply a reload strategy.
type StrategyInput struct {
ResourceName string
ResourceType ResourceType
Namespace string
Hash string
Container *corev1.Container
PodAnnotations map[string]string
AutoReload bool
}
// ReloadSource contains metadata about what triggered a reload.
type ReloadSource struct {
Kind string `json:"kind"`
Name string `json:"name"`
Namespace string `json:"namespace"`
Hash string `json:"hash"`
Containers []string `json:"containers"`
ReloadedAt time.Time `json:"reloadedAt"`
}
// EnvVarStrategy triggers reloads by adding/updating environment variables.
type EnvVarStrategy struct{}
// NewEnvVarStrategy creates a new EnvVarStrategy.
func NewEnvVarStrategy() *EnvVarStrategy {
return &EnvVarStrategy{}
}
func (s *EnvVarStrategy) Name() string {
return string(config.ReloadStrategyEnvVars)
}
// Apply adds, updates, or removes an environment variable to trigger a restart.
func (s *EnvVarStrategy) Apply(input StrategyInput) (bool, error) {
if input.Container == nil {
return false, fmt.Errorf("container is required for env-var strategy")
}
envVarName := s.envVarName(input.ResourceName, input.ResourceType)
if input.Hash == "" {
return s.removeEnvVar(input.Container, envVarName), nil
}
for i := range input.Container.Env {
if input.Container.Env[i].Name == envVarName {
if input.Container.Env[i].Value == input.Hash {
return false, nil
}
input.Container.Env[i].Value = input.Hash
return true, nil
}
}
input.Container.Env = append(input.Container.Env, corev1.EnvVar{
Name: envVarName,
Value: input.Hash,
})
return true, nil
}
func (s *EnvVarStrategy) removeEnvVar(container *corev1.Container, name string) bool {
for i := range container.Env {
if container.Env[i].Name == name {
container.Env[i] = container.Env[len(container.Env)-1]
container.Env = container.Env[:len(container.Env)-1]
return true
}
}
return false
}
func (s *EnvVarStrategy) envVarName(resourceName string, resourceType ResourceType) string {
var postfix string
switch resourceType {
case ResourceTypeConfigMap:
postfix = ConfigmapEnvVarPostfix
case ResourceTypeSecret:
postfix = SecretEnvVarPostfix
case ResourceTypeSecretProviderClass:
postfix = SecretProviderClassEnvVarPostfix
}
return EnvVarPrefix + convertToEnvVarName(resourceName) + "_" + postfix
}
func convertToEnvVarName(text string) string {
var buffer bytes.Buffer
upper := strings.ToUpper(text)
lastCharValid := false
for i := 0; i < len(upper); i++ {
ch := upper[i]
if (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') {
buffer.WriteByte(ch)
lastCharValid = true
} else {
if lastCharValid {
buffer.WriteByte('_')
}
lastCharValid = false
}
}
return buffer.String()
}
// AnnotationStrategy triggers reloads by adding/updating pod template annotations.
type AnnotationStrategy struct {
cfg *config.Config
}
// NewAnnotationStrategy creates a new AnnotationStrategy.
func NewAnnotationStrategy(cfg *config.Config) *AnnotationStrategy {
return &AnnotationStrategy{cfg: cfg}
}
func (s *AnnotationStrategy) Name() string {
return string(config.ReloadStrategyAnnotations)
}
// Apply adds or updates a pod annotation to trigger a restart.
func (s *AnnotationStrategy) Apply(input StrategyInput) (bool, error) {
if input.PodAnnotations == nil {
return false, fmt.Errorf("pod annotations map is required for annotation strategy")
}
containerName := ""
if input.Container != nil {
containerName = input.Container.Name
}
source := ReloadSource{
Kind: string(input.ResourceType),
Name: input.ResourceName,
Namespace: input.Namespace,
Hash: input.Hash,
Containers: []string{containerName},
ReloadedAt: time.Now().UTC(),
}
sourceJSON, err := json.Marshal(source)
if err != nil {
return false, fmt.Errorf("failed to marshal reload source: %w", err)
}
annotationKey := s.cfg.Annotations.LastReloadedFrom
existingValue := input.PodAnnotations[annotationKey]
// Idempotent on kind+name+hash, ignoring ReloadedAt: a timestamped compare
// would force a rollout every reconcile (one CSI rotation fans out to N
// SecretProviderClassPodStatus updates → N rollouts).
if existingValue != "" {
var prev ReloadSource
if err := json.Unmarshal([]byte(existingValue), &prev); err == nil &&
prev.Kind == source.Kind && prev.Name == source.Name && prev.Hash == source.Hash {
return false, nil
}
}
input.PodAnnotations[annotationKey] = string(sourceJSON)
return true, nil
}
// NewStrategy creates a Strategy based on the configuration.
func NewStrategy(cfg *config.Config) Strategy {
switch cfg.ReloadStrategy {
case config.ReloadStrategyAnnotations:
return NewAnnotationStrategy(cfg)
default:
return NewEnvVarStrategy()
}
}
-346
View File
@@ -1,346 +0,0 @@
package reload
import (
"encoding/json"
"testing"
corev1 "k8s.io/api/core/v1"
"github.com/stakater/Reloader/internal/pkg/config"
)
func TestEnvVarStrategy_Apply(t *testing.T) {
strategy := NewEnvVarStrategy()
t.Run("adds new env var", func(t *testing.T) {
container := &corev1.Container{
Name: "test-container",
Env: []corev1.EnvVar{},
}
input := StrategyInput{
ResourceName: "my-config",
ResourceType: ResourceTypeConfigMap,
Namespace: "default",
Hash: "abc123",
Container: container,
}
changed, err := strategy.Apply(input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !changed {
t.Error("expected changed=true for new env var")
}
// Verify env var was added
found := false
for _, env := range container.Env {
if env.Name == "STAKATER_MY_CONFIG_CONFIGMAP" && env.Value == "abc123" {
found = true
break
}
}
if !found {
t.Errorf("expected env var STAKATER_MY_CONFIG_CONFIGMAP=abc123, got %+v", container.Env)
}
})
t.Run("updates existing env var", func(t *testing.T) {
container := &corev1.Container{
Name: "test-container",
Env: []corev1.EnvVar{
{Name: "STAKATER_MY_CONFIG_CONFIGMAP", Value: "old-hash"},
},
}
input := StrategyInput{
ResourceName: "my-config",
ResourceType: ResourceTypeConfigMap,
Namespace: "default",
Hash: "new-hash",
Container: container,
}
changed, err := strategy.Apply(input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !changed {
t.Error("expected changed=true for updated env var")
}
// Verify env var was updated
if container.Env[0].Value != "new-hash" {
t.Errorf("expected env var value=new-hash, got %s", container.Env[0].Value)
}
})
t.Run("no change when hash is same", func(t *testing.T) {
container := &corev1.Container{
Name: "test-container",
Env: []corev1.EnvVar{
{Name: "STAKATER_MY_CONFIG_CONFIGMAP", Value: "same-hash"},
},
}
input := StrategyInput{
ResourceName: "my-config",
ResourceType: ResourceTypeConfigMap,
Namespace: "default",
Hash: "same-hash",
Container: container,
}
changed, err := strategy.Apply(input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if changed {
t.Error("expected changed=false when hash is unchanged")
}
})
t.Run("error when container is nil", func(t *testing.T) {
input := StrategyInput{
ResourceName: "my-config",
ResourceType: ResourceTypeConfigMap,
Namespace: "default",
Hash: "abc123",
Container: nil,
}
_, err := strategy.Apply(input)
if err == nil {
t.Error("expected error for nil container")
}
})
t.Run("secret env var has correct postfix", func(t *testing.T) {
container := &corev1.Container{
Name: "test-container",
Env: []corev1.EnvVar{},
}
input := StrategyInput{
ResourceName: "my-secret",
ResourceType: ResourceTypeSecret,
Namespace: "default",
Hash: "abc123",
Container: container,
}
changed, err := strategy.Apply(input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !changed {
t.Error("expected changed=true")
}
// Verify env var name has SECRET postfix
found := false
for _, env := range container.Env {
if env.Name == "STAKATER_MY_SECRET_SECRET" && env.Value == "abc123" {
found = true
break
}
}
if !found {
t.Errorf("expected env var STAKATER_MY_SECRET_SECRET=abc123, got %+v", container.Env)
}
})
}
func TestEnvVarStrategy_EnvVarName(t *testing.T) {
strategy := NewEnvVarStrategy()
tests := []struct {
resourceName string
resourceType ResourceType
expected string
}{
{"my-config", ResourceTypeConfigMap, "STAKATER_MY_CONFIG_CONFIGMAP"},
{"my-secret", ResourceTypeSecret, "STAKATER_MY_SECRET_SECRET"},
{"app-config-v2", ResourceTypeConfigMap, "STAKATER_APP_CONFIG_V2_CONFIGMAP"},
{"my.dotted.config", ResourceTypeConfigMap, "STAKATER_MY_DOTTED_CONFIG_CONFIGMAP"},
{"MyMixedCase", ResourceTypeConfigMap, "STAKATER_MYMIXEDCASE_CONFIGMAP"},
{"config-with-123-numbers", ResourceTypeConfigMap, "STAKATER_CONFIG_WITH_123_NUMBERS_CONFIGMAP"},
}
for _, tt := range tests {
t.Run(tt.resourceName, func(t *testing.T) {
got := strategy.envVarName(tt.resourceName, tt.resourceType)
if got != tt.expected {
t.Errorf("envVarName(%q, %q) = %q, want %q",
tt.resourceName, tt.resourceType, got, tt.expected)
}
})
}
}
func TestConvertToEnvVarName(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"my-config", "MY_CONFIG"},
{"my.config", "MY_CONFIG"},
{"my_config", "MY_CONFIG"},
{"MY-CONFIG", "MY_CONFIG"},
{"config123", "CONFIG123"},
{"123config", "123CONFIG"},
{"my--config", "MY_CONFIG"},
{"my..config", "MY_CONFIG"},
{"", ""},
{"-leading-dash", "LEADING_DASH"},
{"trailing-dash-", "TRAILING_DASH_"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := convertToEnvVarName(tt.input)
if got != tt.expected {
t.Errorf("convertToEnvVarName(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}
func TestAnnotationStrategy_Apply(t *testing.T) {
cfg := config.NewDefault()
strategy := NewAnnotationStrategy(cfg)
t.Run("adds new annotation", func(t *testing.T) {
annotations := make(map[string]string)
container := &corev1.Container{Name: "test-container"}
input := StrategyInput{
ResourceName: "my-config",
ResourceType: ResourceTypeConfigMap,
Namespace: "default",
Hash: "abc123",
Container: container,
PodAnnotations: annotations,
}
changed, err := strategy.Apply(input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !changed {
t.Error("expected changed=true for new annotation")
}
// Verify annotation was added
annotationValue := annotations[cfg.Annotations.LastReloadedFrom]
if annotationValue == "" {
t.Error("expected annotation to be set")
}
// Verify annotation content
var source ReloadSource
if err := json.Unmarshal([]byte(annotationValue), &source); err != nil {
t.Fatalf("failed to unmarshal annotation: %v", err)
}
if source.Kind != string(ResourceTypeConfigMap) {
t.Errorf("expected kind=%s, got %s", ResourceTypeConfigMap, source.Kind)
}
if source.Name != "my-config" {
t.Errorf("expected name=my-config, got %s", source.Name)
}
if source.Hash != "abc123" {
t.Errorf("expected hash=abc123, got %s", source.Hash)
}
})
t.Run("idempotent for same resource and hash (ignores timestamp)", func(t *testing.T) {
annotations := make(map[string]string)
input := StrategyInput{
ResourceName: "my-config",
ResourceType: ResourceTypeConfigMap,
Namespace: "default",
Hash: "abc123",
Container: &corev1.Container{Name: "c"},
PodAnnotations: annotations,
}
changed, err := strategy.Apply(input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !changed {
t.Fatal("expected changed=true on first apply")
}
firstValue := annotations[cfg.Annotations.LastReloadedFrom]
// Re-applying the identical change must NOT report a change, even though
// a fresh ReloadedAt timestamp would make the serialized value differ.
changed, err = strategy.Apply(input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if changed {
t.Error("expected changed=false when re-applying the same resource+hash")
}
if annotations[cfg.Annotations.LastReloadedFrom] != firstValue {
t.Error("annotation value must not change on an idempotent re-apply")
}
// A different hash (real content change) must trigger an update.
input.Hash = "def456"
changed, err = strategy.Apply(input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !changed {
t.Error("expected changed=true when the hash changes")
}
})
t.Run("error when annotations map is nil", func(t *testing.T) {
input := StrategyInput{
ResourceName: "my-config",
ResourceType: ResourceTypeConfigMap,
Namespace: "default",
Hash: "abc123",
PodAnnotations: nil,
}
_, err := strategy.Apply(input)
if err == nil {
t.Error("expected error for nil annotations map")
}
})
}
func TestNewStrategy(t *testing.T) {
t.Run("default strategy is env-vars", func(t *testing.T) {
cfg := config.NewDefault()
strategy := NewStrategy(cfg)
if strategy.Name() != string(config.ReloadStrategyEnvVars) {
t.Errorf("expected env-vars strategy, got %s", strategy.Name())
}
})
t.Run("annotations strategy when configured", func(t *testing.T) {
cfg := config.NewDefault()
cfg.ReloadStrategy = config.ReloadStrategyAnnotations
strategy := NewStrategy(cfg)
if strategy.Name() != string(config.ReloadStrategyAnnotations) {
t.Errorf("expected annotations strategy, got %s", strategy.Name())
}
})
}
func TestEnvVarNameSecretProviderClass(t *testing.T) {
s := NewEnvVarStrategy()
got := s.envVarName("my-vault-spc", ResourceTypeSecretProviderClass)
want := "STAKATER_MY_VAULT_SPC_SECRETPROVIDERCLASS"
if got != want {
t.Fatalf("envVarName = %q, want %q", got, want)
}
}