feat: scoped multi-namespace mode (Role per namespace, no ClusterRole)

Add a third RBAC posture between watch-globally (ClusterRole) and single
namespace: give Reloader an explicit list of namespaces to watch. The chart
creates a namespace-scoped Role + RoleBinding in each listed namespace (no
ClusterRole), and one install covers them all.

Go:
- new --namespaces flag / options.Namespaces
- resolveWatchNamespaces() picks list -> KUBERNETES_NAMESPACE -> all
- controller creation loops over the watched namespaces
- namespaces-to-ignore is now only honored in global mode (watchGlobally=true);
  in single-namespace and scoped modes the watched set is already explicit

Helm:
- new reloader.namespaces value (active when watchGlobally=false); accepts either
  a YAML list or a comma-separated string for consistency with the sibling
  namespace options
- reloader-watchNamespaces helper (release ns always auto-included, deduped)
- shared reloader-namespaced-rules template reused per namespace
- role.yaml/rolebinding.yaml range over the list; deployment passes --namespaces
- --namespaces-to-ignore only rendered when watchGlobally=true
- fail guard for watchGlobally=true + namespaces set

Tests: unit test for resolveWatchNamespaces; scoped-namespaces e2e case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michał Marszałek
2026-06-26 15:48:38 +02:00
co-authored by Claude Opus 4.8
parent 2cbb7715de
commit 50153d05ea
12 changed files with 444 additions and 130 deletions
+48 -26
View File
@@ -102,6 +102,21 @@ func getHAEnvs() (string, string) {
return podName, podNamespace
}
// resolveWatchNamespaces determines the set of namespaces to watch and whether
// Reloader runs in global (all-namespaces) mode. Precedence:
// 1. an explicit --namespaces list (scoped mode) — watch exactly those namespaces;
// 2. the KUBERNETES_NAMESPACE env var (single-namespace mode);
// 3. otherwise watch all namespaces (global mode).
func resolveWatchNamespaces(namespaces []string, kubernetesNamespace string) ([]string, bool) {
if len(namespaces) > 0 {
return namespaces, false
}
if len(kubernetesNamespace) > 0 {
return []string{kubernetesNamespace}, false
}
return []string{v1.NamespaceAll}, true
}
func startReloader(cmd *cobra.Command, args []string) {
common.GetCommandLineOptions()
err := configureLogging(options.LogFormat, options.LogLevel)
@@ -110,12 +125,11 @@ func startReloader(cmd *cobra.Command, args []string) {
}
logrus.Info("Starting Reloader")
isGlobal := false
currentNamespace := os.Getenv("KUBERNETES_NAMESPACE")
if len(currentNamespace) == 0 {
currentNamespace = v1.NamespaceAll
isGlobal = true
watchNamespaces, isGlobal := resolveWatchNamespaces(options.Namespaces, os.Getenv("KUBERNETES_NAMESPACE"))
if isGlobal {
logrus.Warnf("KUBERNETES_NAMESPACE is unset, will detect changes in all namespaces.")
} else if len(options.Namespaces) > 0 {
logrus.Infof("Watching scoped namespaces: %s", strings.Join(watchNamespaces, ", "))
}
// create the clientset
@@ -129,14 +143,20 @@ func startReloader(cmd *cobra.Command, args []string) {
logrus.Fatal(err)
}
ignoredNamespacesList := options.NamespacesToIgnore
// namespaces-to-ignore and namespace-selector only make sense when watching all
// namespaces. In single-namespace and scoped modes the watched set is already
// explicit, so both are intentionally left empty.
ignoredNamespacesList := []string{}
namespaceLabelSelector := ""
if isGlobal {
ignoredNamespacesList = options.NamespacesToIgnore
namespaceLabelSelector, err = common.GetNamespaceLabelSelector(options.NamespaceSelectors)
if err != nil {
logrus.Fatal(err)
}
} else if len(options.NamespacesToIgnore) > 0 {
logrus.Warnf("namespaces-to-ignore is set but is only honored in global mode (watchGlobally=true); ignoring it.")
}
resourceLabelSelector, err := common.GetResourceLabelSelector(options.ResourceSelectors)
@@ -159,31 +179,33 @@ func startReloader(cmd *cobra.Command, args []string) {
collectors := metrics.SetupPrometheusEndpoint()
var controllers []*controller.Controller
for k := range kube.ResourceMap {
if k == constants.SecretProviderClassController && !shouldRunCSIController() {
continue
}
for _, currentNamespace := range watchNamespaces {
for k := range kube.ResourceMap {
if k == constants.SecretProviderClassController && !shouldRunCSIController() {
continue
}
if ignoredResourcesList.Contains(k) || (len(namespaceLabelSelector) == 0 && k == "namespaces") {
continue
}
if ignoredResourcesList.Contains(k) || (len(namespaceLabelSelector) == 0 && k == "namespaces") {
continue
}
c, err := controller.NewController(clientset, k, currentNamespace, ignoredNamespacesList, namespaceLabelSelector, resourceLabelSelector, collectors)
if err != nil {
logrus.Fatalf("%s", err)
}
c, err := controller.NewController(clientset, k, currentNamespace, ignoredNamespacesList, namespaceLabelSelector, resourceLabelSelector, collectors)
if err != nil {
logrus.Fatalf("%s", err)
}
controllers = append(controllers, c)
controllers = append(controllers, c)
// If HA is enabled we only run the controller when
if options.EnableHA {
continue
// If HA is enabled we only run the controller when
if options.EnableHA {
continue
}
// Now let's start the controller
stop := make(chan struct{})
defer close(stop)
logrus.Infof("Starting Controller to watch resource type: %s in namespace: %s", k, currentNamespace)
go c.Run(1, stop)
}
// Now let's start the controller
stop := make(chan struct{})
defer close(stop)
logrus.Infof("Starting Controller to watch resource type: %s", k)
go c.Run(1, stop)
}
// Run leadership election
+62
View File
@@ -0,0 +1,62 @@
package cmd
import (
"testing"
"github.com/stretchr/testify/assert"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestResolveWatchNamespaces(t *testing.T) {
tests := []struct {
name string
namespaces []string
kubernetesNamespace string
wantNamespaces []string
wantGlobal bool
}{
{
name: "scoped mode takes precedence over env",
namespaces: []string{"team-a", "team-b"},
kubernetesNamespace: "reloader-system",
wantNamespaces: []string{"team-a", "team-b"},
wantGlobal: false,
},
{
name: "scoped mode with single namespace",
namespaces: []string{"team-a"},
kubernetesNamespace: "",
wantNamespaces: []string{"team-a"},
wantGlobal: false,
},
{
name: "single namespace mode from env",
namespaces: nil,
kubernetesNamespace: "reloader-system",
wantNamespaces: []string{"reloader-system"},
wantGlobal: false,
},
{
name: "global mode when nothing set",
namespaces: nil,
kubernetesNamespace: "",
wantNamespaces: []string{v1.NamespaceAll},
wantGlobal: true,
},
{
name: "empty list falls back to env",
namespaces: []string{},
kubernetesNamespace: "reloader-system",
wantNamespaces: []string{"reloader-system"},
wantGlobal: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotNamespaces, gotGlobal := resolveWatchNamespaces(tt.namespaces, tt.kubernetesNamespace)
assert.Equal(t, tt.wantNamespaces, gotNamespaces)
assert.Equal(t, tt.wantGlobal, gotGlobal)
})
}
}
+3
View File
@@ -76,6 +76,9 @@ var (
ResourcesToIgnore = []string{}
// WorkloadTypesToIgnore is a list of workload types to ignore when watching for changes
WorkloadTypesToIgnore = []string{}
// Namespaces is an explicit list of namespaces to watch (scoped mode). When non-empty,
// Reloader watches exactly these namespaces and requires no ClusterRole.
Namespaces = []string{}
// NamespacesToIgnore is a list of namespace names to ignore when watching for changes
NamespacesToIgnore = []string{}
// NamespaceSelectors is a list of namespace selectors to watch for changes
+1
View File
@@ -97,6 +97,7 @@ func ConfigureReloaderFlags(cmd *cobra.Command) {
cmd.PersistentFlags().StringVar(&options.WebhookUrl, "webhook-url", "", "webhook to trigger instead of performing a reload")
cmd.PersistentFlags().StringSliceVar(&options.ResourcesToIgnore, "resources-to-ignore", options.ResourcesToIgnore, "list of resources to ignore (valid options 'configmaps' or 'secrets')")
cmd.PersistentFlags().StringSliceVar(&options.WorkloadTypesToIgnore, "ignored-workload-types", options.WorkloadTypesToIgnore, "list of workload types to ignore (valid options: 'jobs', 'cronjobs', or both)")
cmd.PersistentFlags().StringSliceVar(&options.Namespaces, "namespaces", options.Namespaces, "explicit list of namespaces to watch (scoped mode; creates no ClusterRole)")
cmd.PersistentFlags().StringSliceVar(&options.NamespacesToIgnore, "namespaces-to-ignore", options.NamespacesToIgnore, "list of namespaces to ignore")
cmd.PersistentFlags().StringSliceVar(&options.NamespaceSelectors, "namespace-selector", options.NamespaceSelectors, "list of key:value labels to filter on for namespaces")
cmd.PersistentFlags().StringSliceVar(&options.ResourceSelectors, "resource-label-selector", options.ResourceSelectors, "list of key:value labels to filter on for configmaps and secrets")