added watch namespaces support

This commit is contained in:
Safwan
2026-07-14 13:09:14 +05:00
parent 0cfbe10125
commit 0ef47cdc4d
7 changed files with 270 additions and 26 deletions
+9 -3
View File
@@ -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 {
+34 -6
View File
@@ -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
}
+44
View File
@@ -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")
}
}
+33 -7
View File
@@ -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
}
+112 -1
View File
@@ -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")
}
}
+19 -9
View File
@@ -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,
}
}
+19
View File
@@ -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()