diff --git a/deployments/kubernetes/chart/reloader/README.md b/deployments/kubernetes/chart/reloader/README.md index 3e7df6481..7b453ac47 100644 --- a/deployments/kubernetes/chart/reloader/README.md +++ b/deployments/kubernetes/chart/reloader/README.md @@ -56,11 +56,12 @@ helm uninstall {{RELEASE_NAME}} -n {{NAMESPACE}} | `reloader.reloadOnDelete` | Enable reload on delete events. Valid value are either `true` or `false` | boolean | `false` | | `reloader.syncAfterRestart` | Enable sync after Reloader restarts for **Add** events, works only when reloadOnCreate is `true`. Valid value are either `true` or `false` | boolean | `false` | | `reloader.reloadStrategy` | Strategy to trigger resource restart, set to either `default`, `env-vars` or `annotations` | enumeration | `default` | -| `reloader.ignoreNamespaces` | List of comma separated namespaces to ignore, if multiple are provided, they are combined with the AND operator | string | `""` | +| `reloader.ignoreNamespaces` | List of comma separated namespaces to ignore, if multiple are provided, they are combined with the AND operator. Only honored when `reloader.watchGlobally` is `true`; in single-namespace and scoped (`reloader.namespaces`) modes the watched set is already explicit and this value is ignored. | string | `""` | | `reloader.namespaceSelector` | List of comma separated k8s label selectors for namespaces selection. The parameter only used when `reloader.watchGlobally` is `true`. See [LIST and WATCH filtering](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#list-and-watch-filtering) for more details on label-selector | string | `""` | | `reloader.resourceLabelSelector` | List of comma separated label selectors, if multiple are provided they are combined with the AND operator | string | `""` | | `reloader.logFormat` | Set type of log format. Value could be either `json` or `""` | string | `""` | | `reloader.watchGlobally` | Allow Reloader to watch in all namespaces (`true`) or just in a single namespace (`false`) | boolean | `true` | +| `reloader.namespaces` | Explicit namespaces to watch (scoped mode). When non-empty and `reloader.watchGlobally` is `false`, Reloader watches exactly these namespaces and the chart creates a namespace-scoped Role + RoleBinding in each (no ClusterRole). The release namespace is always included automatically. Accepts either a YAML list (`["team-a","team-b"]`) or a comma-separated string (`"team-a,team-b"`). | list/string | `[]` | | `reloader.enableHA` | Enable leadership election allowing you to run multiple replicas | boolean | `false` | | `reloader.enablePProf` | Enables pprof for profiling | boolean | `false` | | `reloader.pprofAddr` | Address to start pprof server on | string | `:6060` | diff --git a/deployments/kubernetes/chart/reloader/templates/_helpers.tpl b/deployments/kubernetes/chart/reloader/templates/_helpers.tpl index 831be8d30..306da2d72 100644 --- a/deployments/kubernetes/chart/reloader/templates/_helpers.tpl +++ b/deployments/kubernetes/chart/reloader/templates/_helpers.tpl @@ -88,6 +88,136 @@ Create the namespace selector if it does not watch globally {{- end -}} {{- end -}} +{{/* +Effective set of namespaces to watch in scoped mode: the release namespace +(always included so the meta-info ConfigMap, HA leases and events keep working) +plus the user-supplied reloader.namespaces, deduped and sorted. +Returns a JSON-encoded list; consumers use mustFromJson to iterate. +*/}} +{{- define "reloader-watchNamespaces" -}} +{{- $relNs := .Values.namespace | default .Release.Namespace -}} +{{- $ns := .Values.reloader.namespaces | default list -}} +{{- if kindIs "string" $ns -}} +{{- $ns = splitList "," $ns -}} +{{- end -}} +{{- $clean := list -}} +{{- range $ns -}} +{{- $t := . | toString | trim -}} +{{- if $t -}} +{{- $clean = append $clean $t -}} +{{- end -}} +{{- end -}} +{{- $all := concat (list $relNs) $clean | uniq | sortAlpha -}} +{{- $all | toJson -}} +{{- end -}} + +{{/* +Comma-joined form of reloader-watchNamespaces, for the --namespaces CLI flag. +*/}} +{{- define "reloader-watchNamespaces-csv" -}} +{{- include "reloader-watchNamespaces" . | mustFromJson | join "," -}} +{{- end -}} + +{{/* +The namespaced RBAC rules granted to Reloader in every watched namespace. +Shared between the single-namespace Role and the per-namespace scoped Roles so +the rule set is defined once. Expects the root context ($) as its argument. +*/}} +{{- define "reloader-namespaced-rules" }} + - apiGroups: + - "" + resources: +{{- if .Values.reloader.ignoreSecrets }}{{- else }} + - secrets +{{- end }} +{{- if .Values.reloader.ignoreConfigMaps }}{{- else }} + - configmaps +{{- end }} + verbs: + - list + - get + - watch +{{- if and (.Capabilities.APIVersions.Has "apps.openshift.io/v1") (.Values.reloader.isOpenshift) }} + - apiGroups: + - "apps.openshift.io" + - "" + resources: + - deploymentconfigs + verbs: + - list + - get + - update + - patch +{{- end }} +{{- if and (.Capabilities.APIVersions.Has "argoproj.io/v1alpha1") (.Values.reloader.isArgoRollouts) }} + - apiGroups: + - "argoproj.io" + - "" + resources: + - rollouts + verbs: + - list + - get + - update + - patch +{{- end }} + - apiGroups: + - "apps" + resources: + - deployments + - daemonsets + - statefulsets + verbs: + - list + - get + - update + - patch + - apiGroups: + - "batch" + resources: + - cronjobs + verbs: + - list + - get + - apiGroups: + - "batch" + resources: + - jobs + verbs: + - create + - delete + - list + - get +{{- if .Values.reloader.enableHA }} + - apiGroups: + - "coordination.k8s.io" + resources: + - leases + verbs: + - create + - get + - update +{{- end}} +{{- if .Values.reloader.enableCSIIntegration }} + - apiGroups: + - "secrets-store.csi.x-k8s.io" + resources: + - secretproviderclasspodstatuses + - secretproviderclasses + verbs: + - list + - get + - watch +{{- end}} + - apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +{{- end -}} + {{/* Normalizes global.imagePullSecrets to a list of objects with name fields. Supports both of these in values.yaml: diff --git a/deployments/kubernetes/chart/reloader/templates/deployment.yaml b/deployments/kubernetes/chart/reloader/templates/deployment.yaml index 31ab80895..b3ac972e1 100644 --- a/deployments/kubernetes/chart/reloader/templates/deployment.yaml +++ b/deployments/kubernetes/chart/reloader/templates/deployment.yaml @@ -144,7 +144,7 @@ spec: fieldPath: {{ $value | quote}} {{- end }} {{- end }} - {{- if eq .Values.reloader.watchGlobally false }} + {{- if and (eq .Values.reloader.watchGlobally false) (not .Values.reloader.namespaces) }} - name: KUBERNETES_NAMESPACE valueFrom: fieldRef: @@ -213,7 +213,7 @@ spec: {{- . | toYaml | nindent 10 }} {{- end }} {{- end }} - {{- if or (.Values.reloader.logFormat) (.Values.reloader.logLevel) (.Values.reloader.ignoreSecrets) (.Values.reloader.ignoreNamespaces) (include "reloader-namespaceSelector" .) (.Values.reloader.resourceLabelSelector) (.Values.reloader.ignoreConfigMaps) (.Values.reloader.custom_annotations) (eq .Values.reloader.isArgoRollouts true) (eq .Values.reloader.reloadOnCreate true) (eq .Values.reloader.reloadOnDelete true) (ne .Values.reloader.reloadStrategy "default") (.Values.reloader.enableHA) (.Values.reloader.autoReloadAll) (.Values.reloader.ignoreJobs) (.Values.reloader.ignoreCronJobs) (.Values.reloader.enableCSIIntegration)}} + {{- if or (.Values.reloader.logFormat) (.Values.reloader.logLevel) (.Values.reloader.ignoreSecrets) (and .Values.reloader.ignoreNamespaces .Values.reloader.watchGlobally) (.Values.reloader.namespaces) (include "reloader-namespaceSelector" .) (.Values.reloader.resourceLabelSelector) (.Values.reloader.ignoreConfigMaps) (.Values.reloader.custom_annotations) (eq .Values.reloader.isArgoRollouts true) (eq .Values.reloader.reloadOnCreate true) (eq .Values.reloader.reloadOnDelete true) (ne .Values.reloader.reloadStrategy "default") (.Values.reloader.enableHA) (.Values.reloader.autoReloadAll) (.Values.reloader.ignoreJobs) (.Values.reloader.ignoreCronJobs) (.Values.reloader.enableCSIIntegration)}} args: {{- if .Values.reloader.logFormat }} - "--log-format={{ .Values.reloader.logFormat }}" @@ -234,7 +234,10 @@ spec: {{- else if .Values.reloader.ignoreCronJobs }} - "--ignored-workload-types=cronjobs" {{- end }} - {{- if .Values.reloader.ignoreNamespaces }} + {{- if .Values.reloader.namespaces }} + - "--namespaces={{ include "reloader-watchNamespaces-csv" . }}" + {{- end }} + {{- if and .Values.reloader.ignoreNamespaces .Values.reloader.watchGlobally }} - "--namespaces-to-ignore={{ .Values.reloader.ignoreNamespaces }}" {{- end }} {{- if (include "reloader-namespaceSelector" .) }} diff --git a/deployments/kubernetes/chart/reloader/templates/role.yaml b/deployments/kubernetes/chart/reloader/templates/role.yaml index 7355d873b..28c535d62 100644 --- a/deployments/kubernetes/chart/reloader/templates/role.yaml +++ b/deployments/kubernetes/chart/reloader/templates/role.yaml @@ -1,9 +1,34 @@ +{{- if and .Values.reloader.watchGlobally .Values.reloader.namespaces }} +{{- fail "reloader.namespaces is set but reloader.watchGlobally is true; set reloader.watchGlobally=false to use scoped namespace mode." }} +{{- end }} {{- if and (not (.Values.reloader.watchGlobally)) (.Values.reloader.rbac.enabled) }} -{{- if (.Capabilities.APIVersions.Has "rbac.authorization.k8s.io/v1") }} -apiVersion: rbac.authorization.k8s.io/v1 -{{ else }} -apiVersion: rbac.authorization.k8s.io/v1beta1 +{{- $apiVersion := "rbac.authorization.k8s.io/v1" }} +{{- if not (.Capabilities.APIVersions.Has "rbac.authorization.k8s.io/v1") }} +{{- $apiVersion = "rbac.authorization.k8s.io/v1beta1" }} +{{- end }} +{{- if .Values.reloader.namespaces }} +{{- range $ns := (include "reloader-watchNamespaces" . | mustFromJson) }} +apiVersion: {{ $apiVersion }} +kind: Role +metadata: + annotations: +{{ include "reloader-helm3.annotations" $ | indent 4 }} + labels: +{{ include "reloader-labels.chart" $ | indent 4 }} +{{- if $.Values.reloader.rbac.labels }} +{{ tpl (toYaml $.Values.reloader.rbac.labels) $ | indent 4 }} +{{- end }} +{{- if $.Values.reloader.matchLabels }} +{{ tpl (toYaml $.Values.reloader.matchLabels) $ | indent 4 }} +{{- end }} + name: {{ template "reloader-fullname" $ }}-role + namespace: {{ $ns }} +rules: +{{- include "reloader-namespaced-rules" $ }} +--- {{- end }} +{{- else }} +apiVersion: {{ $apiVersion }} kind: Role metadata: annotations: @@ -19,98 +44,8 @@ metadata: name: {{ template "reloader-fullname" . }}-role namespace: {{ .Values.namespace | default .Release.Namespace }} rules: - - apiGroups: - - "" - resources: -{{- if .Values.reloader.ignoreSecrets }}{{- else }} - - secrets -{{- end }} -{{- if .Values.reloader.ignoreConfigMaps }}{{- else }} - - configmaps -{{- end }} - verbs: - - list - - get - - watch -{{- if and (.Capabilities.APIVersions.Has "apps.openshift.io/v1") (.Values.reloader.isOpenshift) }} - - apiGroups: - - "apps.openshift.io" - - "" - resources: - - deploymentconfigs - verbs: - - list - - get - - update - - patch -{{- end }} -{{- if and (.Capabilities.APIVersions.Has "argoproj.io/v1alpha1") (.Values.reloader.isArgoRollouts) }} - - apiGroups: - - "argoproj.io" - - "" - resources: - - rollouts - verbs: - - list - - get - - update - - patch +{{- include "reloader-namespaced-rules" . }} {{- end }} - - apiGroups: - - "apps" - resources: - - deployments - - daemonsets - - statefulsets - verbs: - - list - - get - - update - - patch - - apiGroups: - - "batch" - resources: - - cronjobs - verbs: - - list - - get - - apiGroups: - - "batch" - resources: - - jobs - verbs: - - create - - delete - - list - - get -{{- if .Values.reloader.enableHA }} - - apiGroups: - - "coordination.k8s.io" - resources: - - leases - verbs: - - create - - get - - update -{{- end}} -{{- if .Values.reloader.enableCSIIntegration }} - - apiGroups: - - "secrets-store.csi.x-k8s.io" - resources: - - secretproviderclasspodstatuses - - secretproviderclasses - verbs: - - list - - get - - watch -{{- end}} - - apiGroups: - - "" - resources: - - events - verbs: - - create - - patch {{- end }} --- diff --git a/deployments/kubernetes/chart/reloader/templates/rolebinding.yaml b/deployments/kubernetes/chart/reloader/templates/rolebinding.yaml index 5cf4cf38b..7d73b1822 100644 --- a/deployments/kubernetes/chart/reloader/templates/rolebinding.yaml +++ b/deployments/kubernetes/chart/reloader/templates/rolebinding.yaml @@ -1,9 +1,37 @@ {{- if and (not (.Values.reloader.watchGlobally)) (.Values.reloader.rbac.enabled) }} -{{- if (.Capabilities.APIVersions.Has "rbac.authorization.k8s.io/v1") }} -apiVersion: rbac.authorization.k8s.io/v1 -{{ else }} -apiVersion: rbac.authorization.k8s.io/v1beta1 +{{- $apiVersion := "rbac.authorization.k8s.io/v1" }} +{{- if not (.Capabilities.APIVersions.Has "rbac.authorization.k8s.io/v1") }} +{{- $apiVersion = "rbac.authorization.k8s.io/v1beta1" }} {{- end }} +{{- if .Values.reloader.namespaces }} +{{- range $ns := (include "reloader-watchNamespaces" . | mustFromJson) }} +apiVersion: {{ $apiVersion }} +kind: RoleBinding +metadata: + annotations: +{{ include "reloader-helm3.annotations" $ | indent 4 }} + labels: +{{ include "reloader-labels.chart" $ | indent 4 }} +{{- if $.Values.reloader.rbac.labels }} +{{ tpl (toYaml $.Values.reloader.rbac.labels) $ | indent 4 }} +{{- end }} +{{- if $.Values.reloader.matchLabels }} +{{ tpl (toYaml $.Values.reloader.matchLabels) $ | indent 4 }} +{{- end }} + name: {{ template "reloader-fullname" $ }}-role-binding + namespace: {{ $ns }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ template "reloader-fullname" $ }}-role +subjects: + - kind: ServiceAccount + name: {{ template "reloader-serviceAccountName" $ }} + namespace: {{ $.Values.namespace | default $.Release.Namespace }} +--- +{{- end }} +{{- else }} +apiVersion: {{ $apiVersion }} kind: RoleBinding metadata: annotations: @@ -27,6 +55,7 @@ subjects: name: {{ template "reloader-serviceAccountName" . }} namespace: {{ .Values.namespace | default .Release.Namespace }} {{- end }} +{{- end }} --- {{- if .Values.reloader.rbac.enabled }} diff --git a/deployments/kubernetes/chart/reloader/values.yaml b/deployments/kubernetes/chart/reloader/values.yaml index eb784cb3d..15a22bcf8 100644 --- a/deployments/kubernetes/chart/reloader/values.yaml +++ b/deployments/kubernetes/chart/reloader/values.yaml @@ -45,6 +45,14 @@ reloader: logFormat: "" # json logLevel: info # Log level to use (trace, debug, info, warning, error, fatal and panic) watchGlobally: true + # Scoped mode: explicit list of namespaces to watch. When non-empty (and watchGlobally + # is false), Reloader watches exactly these namespaces and the chart creates a namespace + # scoped Role + RoleBinding in each one — no ClusterRole is created. The release namespace + # is always included automatically. Leave empty ([]) for the default single-namespace or + # global behavior controlled by watchGlobally. + # Accepts either a YAML list (e.g. ["team-a", "team-b"]) or a comma-separated string + # (e.g. "team-a,team-b") + namespaces: [] # Set to true to enable leadership election allowing you to run multiple replicas enableHA: false # Set to true to enable pprof for profiling diff --git a/deployments/kubernetes/templates/chart/values.yaml.tmpl b/deployments/kubernetes/templates/chart/values.yaml.tmpl index 6099983e9..25722bb8c 100644 --- a/deployments/kubernetes/templates/chart/values.yaml.tmpl +++ b/deployments/kubernetes/templates/chart/values.yaml.tmpl @@ -18,6 +18,13 @@ reloader: ignoreNamespaces: "" # Comma separated list of namespaces to ignore logFormat: "" #json watchGlobally: true + # Scoped mode: explicit list of namespaces to watch. When non-empty (and watchGlobally + # is false), Reloader watches exactly these namespaces and the chart creates a namespace + # scoped Role + RoleBinding in each one — no ClusterRole is created. The release namespace + # is always included automatically. + # Accepts either a YAML list (e.g. ["team-a", "team-b"]) or a comma-separated string + # (e.g. "team-a,team-b") + namespaces: [] # Set to true if you have a pod security policy that enforces readOnlyRootFilesystem readOnlyRootFileSystem: false legacy: diff --git a/internal/pkg/cmd/reloader.go b/internal/pkg/cmd/reloader.go index 67f357fcf..6f3dbc37f 100644 --- a/internal/pkg/cmd/reloader.go +++ b/internal/pkg/cmd/reloader.go @@ -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 +} + // namespaceWatchScopeMessage returns the startup log message describing the // namespace scope Reloader will watch when KUBERNETES_NAMESPACE is unset // (global mode). It reflects --namespaces-to-ignore so the log is not @@ -124,11 +139,9 @@ 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 && len(options.Namespaces) > 0 { + logrus.Infof("Watching scoped namespaces: %s", strings.Join(watchNamespaces, ", ")) } // create the clientset @@ -142,17 +155,21 @@ func startReloader(cmd *cobra.Command, args []string) { logrus.Fatal(err) } - ignoredNamespacesList := options.NamespacesToIgnore - if isGlobal { - logrus.Warn(namespaceWatchScopeMessage(ignoredNamespacesList)) - } + // 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 + logrus.Warn(namespaceWatchScopeMessage(ignoredNamespacesList)) 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) @@ -175,31 +192,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 - } - - 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) - } - - controllers = append(controllers, c) - - // If HA is enabled we only run the controller when - if options.EnableHA { - 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 + } + + c, err := controller.NewController(clientset, k, currentNamespace, ignoredNamespacesList, namespaceLabelSelector, resourceLabelSelector, collectors) + if err != nil { + logrus.Fatalf("%s", err) + } + + controllers = append(controllers, c) + + // 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 diff --git a/internal/pkg/cmd/reloader_test.go b/internal/pkg/cmd/reloader_test.go index a5a68e9ad..ef9b032eb 100644 --- a/internal/pkg/cmd/reloader_test.go +++ b/internal/pkg/cmd/reloader_test.go @@ -1,6 +1,65 @@ package cmd -import "testing" +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) + }) + } +} func TestNamespaceWatchScopeMessage(t *testing.T) { tests := []struct { diff --git a/internal/pkg/options/flags.go b/internal/pkg/options/flags.go index 62f285302..cb88daf16 100644 --- a/internal/pkg/options/flags.go +++ b/internal/pkg/options/flags.go @@ -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 diff --git a/internal/pkg/util/util.go b/internal/pkg/util/util.go index 8a1bedf6f..b4809b207 100644 --- a/internal/pkg/util/util.go +++ b/internal/pkg/util/util.go @@ -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") diff --git a/test/e2e/flags/watch_globally_test.go b/test/e2e/flags/watch_globally_test.go index 96e3fb2d5..19bb7de34 100644 --- a/test/e2e/flags/watch_globally_test.go +++ b/test/e2e/flags/watch_globally_test.go @@ -108,6 +108,119 @@ var _ = Describe("Watch Globally Flag Tests", Serial, func() { }) }) + Context("with scoped namespaces list (watchGlobally=false + reloader.namespaces)", func() { + var scopedNS string + + BeforeEach(func() { + scopedNS = "scoped-" + utils.RandName("ns") + Expect(utils.CreateNamespace(ctx, kubeClient, scopedNS)).To(Succeed()) + Expect(utils.CreateNamespace(ctx, kubeClient, otherNS)).To(Succeed()) + + // Watch only scopedNS explicitly; the release namespace (testNamespace) + // is auto-included by the chart. otherNS is intentionally left out. + err := deployReloaderWithFlags(map[string]string{ + "reloader.watchGlobally": "false", + "reloader.namespaces": "{" + scopedNS + "}", + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(waitForReloaderReady()).To(Succeed()) + }) + + AfterEach(func() { + _ = utils.DeleteDeployment(ctx, kubeClient, scopedNS, deploymentName) + _ = utils.DeleteConfigMap(ctx, kubeClient, scopedNS, configMapName) + _ = undeployReloader() + _ = utils.DeleteNamespace(ctx, kubeClient, scopedNS) + _ = utils.DeleteNamespace(ctx, kubeClient, otherNS) + }) + + It("should reload workloads in a listed namespace", func() { + By("Creating a ConfigMap in the listed namespace") + _, err := utils.CreateConfigMap(ctx, kubeClient, scopedNS, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment in the listed namespace with auto annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, scopedNS, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithAnnotations(utils.BuildAutoTrueAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, scopedNS, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, scopedNS, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, scopedNS, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment in a listed namespace should reload") + }) + + It("should reload workloads in Reloader's auto-included release namespace", func() { + By("Creating a ConfigMap in Reloader's namespace") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment in Reloader's namespace with auto annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithAnnotations(utils.BuildAutoTrueAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded (release namespace is auto-included)") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment in Reloader's auto-included namespace should reload") + }) + + It("should NOT reload workloads in an unlisted namespace", func() { + By("Creating a ConfigMap in an unlisted namespace") + _, err := utils.CreateConfigMap(ctx, kubeClient, otherNS, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment in an unlisted namespace with auto annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, otherNS, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithAnnotations(utils.BuildAutoTrueAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, otherNS, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap in the unlisted namespace") + err = utils.UpdateConfigMap(ctx, kubeClient, otherNS, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Deployment was NOT reloaded (namespace not in the list)") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded(ctx, otherNS, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "Deployment in an unlisted namespace should NOT reload") + }) + }) + Context("with watchGlobally=true flag (default)", func() { var globalNS string