mirror of
https://github.com/stakater/Reloader.git
synced 2026-08-20 04:26:28 +00:00
added watch namespaces support
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user