From 0ef47cdc4d2e37e3b8ca3a149a9bf256e8dc06be Mon Sep 17 00:00:00 2001 From: Safwan Date: Tue, 14 Jul 2026 13:09:14 +0500 Subject: [PATCH] added watch namespaces support --- cmd/reloader/main.go | 12 ++- internal/pkg/config/config.go | 40 +++++++-- internal/pkg/config/config_test.go | 44 +++++++++ internal/pkg/config/flags.go | 40 +++++++-- internal/pkg/config/flags_test.go | 113 +++++++++++++++++++++++- internal/pkg/controller/manager.go | 28 ++++-- internal/pkg/controller/manager_test.go | 19 ++++ 7 files changed, 270 insertions(+), 26 deletions(-) diff --git a/cmd/reloader/main.go b/cmd/reloader/main.go index 35151f9b..32535215 100644 --- a/cmd/reloader/main.go +++ b/cmd/reloader/main.go @@ -81,10 +81,16 @@ func run(cmd *cobra.Command, args []string) error { log.Info("Starting Reloader") - if cfg.WatchedNamespace != "" { - log.Info("watching single namespace", "namespace", cfg.WatchedNamespace) - } else { + // Enforce master-parity scope semantics before reconcilers/manager read the + // config: selector and ignore lists are only honored in global mode. + for _, w := range cfg.ApplyNamespaceScope() { + log.Info(w) + } + + if cfg.IsGlobalMode() { log.Info("watching all namespaces") + } else { + log.Info("watching scoped namespaces", "namespaces", cfg.WatchedNamespaces) } if len(cfg.NamespaceSelectors) > 0 { diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index b63611e7..726a6e8b 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -54,10 +54,10 @@ type Config struct { 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"` + Alerting AlertingConfig `json:"alerting"` + LeaderElection LeaderElectionConfig `json:"leaderElection"` + WatchedNamespaces []string `json:"watchedNamespaces,omitempty"` + SyncPeriod time.Duration `json:"syncPeriod"` } // AnnotationConfig holds customizable annotation keys. @@ -137,8 +137,8 @@ func NewDefault() *Config { RetryPeriod: 2 * time.Second, ReleaseOnCancel: true, }, - WatchedNamespace: "", - SyncPeriod: 0, + WatchedNamespaces: []string{}, + SyncPeriod: 0, } } @@ -195,3 +195,31 @@ func (c *Config) IsNamespaceIgnored(namespace string) bool { } return false } + +// IsGlobalMode reports whether Reloader watches all namespaces. Global mode is +// the absence of an explicit watched-namespace list. +func (c *Config) IsGlobalMode() bool { + return len(c.WatchedNamespaces) == 0 +} + +// ApplyNamespaceScope enforces master-parity semantics: namespace-selector and +// namespaces-to-ignore are only honored in global (all-namespaces) mode. In +// scoped or single-namespace mode the watched set is already explicit, so both +// are cleared. It returns human-readable warnings for any setting it dropped so +// the caller can log them. +func (c *Config) ApplyNamespaceScope() []string { + if c.IsGlobalMode() { + return nil + } + var warnings []string + if len(c.NamespaceSelectors) > 0 { + warnings = append(warnings, "namespace-selector is set but is only honored in global mode; ignoring it") + c.NamespaceSelectors = nil + c.NamespaceSelectorStrings = nil + } + if len(c.IgnoredNamespaces) > 0 { + warnings = append(warnings, "namespaces-to-ignore is set but is only honored in global mode; ignoring it") + c.IgnoredNamespaces = nil + } + return warnings +} diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go index bc6ea0a1..62d1a610 100644 --- a/internal/pkg/config/config_test.go +++ b/internal/pkg/config/config_test.go @@ -3,6 +3,8 @@ package config import ( "testing" "time" + + "k8s.io/apimachinery/pkg/labels" ) func TestNewDefault(t *testing.T) { @@ -218,3 +220,45 @@ func TestConfig_IsNamespaceIgnored(t *testing.T) { ) } } + +func TestIsGlobalMode(t *testing.T) { + c := &Config{WatchedNamespaces: nil} + if !c.IsGlobalMode() { + t.Errorf("empty WatchedNamespaces should be global mode") + } + c.WatchedNamespaces = []string{"team-a"} + if c.IsGlobalMode() { + t.Errorf("non-empty WatchedNamespaces should not be global mode") + } +} + +func TestApplyNamespaceScope_GlobalKeepsSettings(t *testing.T) { + c := &Config{ + WatchedNamespaces: nil, + IgnoredNamespaces: []string{"kube-system"}, + NamespaceSelectors: []labels.Selector{labels.Everything()}, + } + warnings := c.ApplyNamespaceScope() + if len(warnings) != 0 { + t.Errorf("global mode should produce no warnings, got %v", warnings) + } + if len(c.IgnoredNamespaces) != 1 || len(c.NamespaceSelectors) != 1 { + t.Errorf("global mode should keep selectors and ignored namespaces") + } +} + +func TestApplyNamespaceScope_ScopedClearsSettings(t *testing.T) { + c := &Config{ + WatchedNamespaces: []string{"team-a"}, + IgnoredNamespaces: []string{"kube-system"}, + NamespaceSelectors: []labels.Selector{labels.Everything()}, + NamespaceSelectorStrings: []string{"env=prod"}, + } + warnings := c.ApplyNamespaceScope() + if len(warnings) != 2 { + t.Errorf("scoped mode should warn about both dropped settings, got %v", warnings) + } + if len(c.IgnoredNamespaces) != 0 || len(c.NamespaceSelectors) != 0 || len(c.NamespaceSelectorStrings) != 0 { + t.Errorf("scoped mode should clear selectors and ignored namespaces") + } +} diff --git a/internal/pkg/config/flags.go b/internal/pkg/config/flags.go index 784fef10..969ffb59 100644 --- a/internal/pkg/config/flags.go +++ b/internal/pkg/config/flags.go @@ -223,10 +223,11 @@ func BindFlags(fs *pflag.FlagSet, cfg *Config) { "Annotation to indicate when a deployment was paused by Reloader", ) - // Watched namespace (for single-namespace mode) - fs.String( - "watch-namespace", cfg.WatchedNamespace, - "Namespace to watch (empty for all namespaces)", + // Watched namespaces (scoped mode). Empty means watch all namespaces; + // KUBERNETES_NAMESPACE (env) is used as a single-namespace fallback. + fs.StringSlice( + "namespaces", nil, + "explicit list of namespaces to watch (scoped mode; creates no ClusterRole)", ) // Alerting @@ -294,9 +295,16 @@ func ApplyFlags(cfg *Config) error { cfg.MetricsAddr = v.GetString("metrics-addr") cfg.HealthAddr = v.GetString("health-addr") cfg.PProfAddr = v.GetString("pprof-addr") - cfg.WatchedNamespace = v.GetString("watch-namespace") - if cfg.WatchedNamespace == "" { - cfg.WatchedNamespace = v.GetString("KUBERNETES_NAMESPACE") + // Namespace scope: an explicit --namespaces list takes precedence (scoped + // mode); otherwise fall back to KUBERNETES_NAMESPACE for single-namespace + // mode; an empty result means global (all-namespaces) mode. + // Trim and drop empty entries from the slice to prevent empty strings from + // being treated as "watch all namespaces" by controller-runtime. + cfg.WatchedNamespaces = trimAndDropEmptyStrings(v.GetStringSlice("namespaces")) + if len(cfg.WatchedNamespaces) == 0 { + if ns := v.GetString("KUBERNETES_NAMESPACE"); ns != "" { + cfg.WatchedNamespaces = []string{ns} + } } // Leader election @@ -412,3 +420,21 @@ func splitAndTrim(s string) []string { } return result } + +// trimAndDropEmptyStrings trims whitespace from each string in a slice and drops empty entries. +func trimAndDropEmptyStrings(ss []string) []string { + if len(ss) == 0 { + return nil + } + result := make([]string, 0, len(ss)) + for _, s := range ss { + s = strings.TrimSpace(s) + if s != "" { + result = append(result, s) + } + } + if len(result) == 0 { + return nil + } + return result +} diff --git a/internal/pkg/config/flags_test.go b/internal/pkg/config/flags_test.go index f9c6819a..c7e6b975 100644 --- a/internal/pkg/config/flags_test.go +++ b/internal/pkg/config/flags_test.go @@ -65,7 +65,7 @@ func TestBindFlags(t *testing.T) { "ignore-annotation", "pause-deployment-annotation", "pause-deployment-time-annotation", - "watch-namespace", + "namespaces", "alert-on-reload", "alert-webhook-url", "alert-sink", @@ -582,3 +582,114 @@ func TestSplitAndTrim(t *testing.T) { ) } } + +func TestApplyFlags_NamespacesScoped(t *testing.T) { + resetViper() + cfg := NewDefault() + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + BindFlags(fs, cfg) + + if err := fs.Parse([]string{"--namespaces=team-a,team-b"}); err != nil { + t.Fatalf("Parse() error = %v", err) + } + if err := ApplyFlags(cfg); err != nil { + t.Fatalf("ApplyFlags() error = %v", err) + } + + if len(cfg.WatchedNamespaces) != 2 { + t.Fatalf("WatchedNamespaces length = %d, want 2", len(cfg.WatchedNamespaces)) + } + if cfg.WatchedNamespaces[0] != "team-a" || cfg.WatchedNamespaces[1] != "team-b" { + t.Errorf("WatchedNamespaces = %v", cfg.WatchedNamespaces) + } + if cfg.IsGlobalMode() { + t.Errorf("explicit namespaces should not be global mode") + } +} + +func TestApplyFlags_NamespacesFromEnv(t *testing.T) { + resetViper() + t.Setenv("KUBERNETES_NAMESPACE", "single-ns") + cfg := NewDefault() + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + BindFlags(fs, cfg) + + if err := fs.Parse([]string{}); err != nil { + t.Fatalf("Parse() error = %v", err) + } + if err := ApplyFlags(cfg); err != nil { + t.Fatalf("ApplyFlags() error = %v", err) + } + + if len(cfg.WatchedNamespaces) != 1 || cfg.WatchedNamespaces[0] != "single-ns" { + t.Errorf("WatchedNamespaces = %v, want [single-ns]", cfg.WatchedNamespaces) + } +} + +func TestApplyFlags_NamespacesGlobal(t *testing.T) { + resetViper() + t.Setenv("KUBERNETES_NAMESPACE", "") + cfg := NewDefault() + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + BindFlags(fs, cfg) + + if err := fs.Parse([]string{}); err != nil { + t.Fatalf("Parse() error = %v", err) + } + if err := ApplyFlags(cfg); err != nil { + t.Fatalf("ApplyFlags() error = %v", err) + } + + if len(cfg.WatchedNamespaces) != 0 { + t.Errorf("WatchedNamespaces = %v, want empty (global)", cfg.WatchedNamespaces) + } + if !cfg.IsGlobalMode() { + t.Errorf("no namespaces and no env should be global mode") + } +} + +func TestApplyFlags_NamespacesTrimsEmptyEntries(t *testing.T) { + resetViper() + cfg := NewDefault() + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + BindFlags(fs, cfg) + + if err := fs.Parse([]string{"--namespaces=team-a, ,team-b,"}); err != nil { + t.Fatalf("Parse() error = %v", err) + } + if err := ApplyFlags(cfg); err != nil { + t.Fatalf("ApplyFlags() error = %v", err) + } + + if len(cfg.WatchedNamespaces) != 2 { + t.Fatalf("WatchedNamespaces length = %d, want 2", len(cfg.WatchedNamespaces)) + } + if cfg.WatchedNamespaces[0] != "team-a" || cfg.WatchedNamespaces[1] != "team-b" { + t.Errorf("WatchedNamespaces = %v, want [team-a team-b]", cfg.WatchedNamespaces) + } + if cfg.IsGlobalMode() { + t.Errorf("trimmed namespaces should not be global mode") + } +} + +func TestApplyFlags_NamespacesAllEmptyIsGlobal(t *testing.T) { + resetViper() + t.Setenv("KUBERNETES_NAMESPACE", "") + cfg := NewDefault() + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + BindFlags(fs, cfg) + + if err := fs.Parse([]string{"--namespaces=, ,"}); err != nil { + t.Fatalf("Parse() error = %v", err) + } + if err := ApplyFlags(cfg); err != nil { + t.Fatalf("ApplyFlags() error = %v", err) + } + + if len(cfg.WatchedNamespaces) != 0 { + t.Errorf("WatchedNamespaces = %v, want empty (global)", cfg.WatchedNamespaces) + } + if !cfg.IsGlobalMode() { + t.Errorf("all-empty namespaces should be global mode") + } +} diff --git a/internal/pkg/controller/manager.go b/internal/pkg/controller/manager.go index 869bdf00..2c561e16 100644 --- a/internal/pkg/controller/manager.go +++ b/internal/pkg/controller/manager.go @@ -45,6 +45,20 @@ func AddOptionalSchemes(argoRolloutsEnabled, deploymentConfigEnabled, csiEnabled } } +// buildDefaultNamespaces returns the controller-runtime cache namespace scoping +// for the given watched namespaces. An empty input yields nil, meaning the cache +// watches all namespaces. +func buildDefaultNamespaces(namespaces []string) map[string]cache.Config { + if len(namespaces) == 0 { + return nil + } + out := make(map[string]cache.Config, len(namespaces)) + for _, ns := range namespaces { + out[ns] = cache.Config{} + } + return out +} + // ManagerOptions contains options for creating a new Manager. type ManagerOptions struct { Config *config.Config @@ -79,13 +93,11 @@ func NewManager(opts ManagerOptions) (ctrl.Manager, error) { RetryPeriod: &le.RetryPeriod, } - if cfg.WatchedNamespace != "" { + if nsScope := buildDefaultNamespaces(cfg.WatchedNamespaces); nsScope != nil { mgrOpts.Cache = cache.Options{ - DefaultNamespaces: map[string]cache.Config{ - cfg.WatchedNamespace: {}, - }, + DefaultNamespaces: nsScope, } - opts.Log.Info("namespace filtering enabled", "namespace", cfg.WatchedNamespace) + opts.Log.Info("namespace filtering enabled", "namespaces", cfg.WatchedNamespaces) } mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), mgrOpts) @@ -130,11 +142,9 @@ func NewManagerWithRestConfig(opts ManagerOptions, restConfig *rest.Config) (ctr RetryPeriod: &le.RetryPeriod, } - if cfg.WatchedNamespace != "" { + if nsScope := buildDefaultNamespaces(cfg.WatchedNamespaces); nsScope != nil { mgrOpts.Cache = cache.Options{ - DefaultNamespaces: map[string]cache.Config{ - cfg.WatchedNamespace: {}, - }, + DefaultNamespaces: nsScope, } } diff --git a/internal/pkg/controller/manager_test.go b/internal/pkg/controller/manager_test.go index 7b671c00..863d1acd 100644 --- a/internal/pkg/controller/manager_test.go +++ b/internal/pkg/controller/manager_test.go @@ -9,6 +9,25 @@ import ( clientgoscheme "k8s.io/client-go/kubernetes/scheme" ) +func TestBuildDefaultNamespaces(t *testing.T) { + if got := buildDefaultNamespaces(nil); got != nil { + t.Errorf("empty input should return nil, got %v", got) + } + if got := buildDefaultNamespaces([]string{}); got != nil { + t.Errorf("empty slice should return nil, got %v", got) + } + + got := buildDefaultNamespaces([]string{"team-a", "team-b", "team-c"}) + if len(got) != 3 { + t.Fatalf("expected 3 entries, got %d", len(got)) + } + for _, ns := range []string{"team-a", "team-b", "team-c"} { + if _, ok := got[ns]; !ok { + t.Errorf("missing namespace %q in %v", ns, got) + } + } +} + func TestAddOptionalSchemesRegistersCSI(t *testing.T) { // Reset to a clean scheme for the test. runtimeScheme = runtime.NewScheme()