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