diff --git a/deployments/kubernetes/chart/reloader/README.md b/deployments/kubernetes/chart/reloader/README.md index 7b453ac4..6eb65d21 100644 --- a/deployments/kubernetes/chart/reloader/README.md +++ b/deployments/kubernetes/chart/reloader/README.md @@ -61,7 +61,7 @@ helm uninstall {{RELEASE_NAME}} -n {{NAMESPACE}} | `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.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 not watched for reloads unless you list it explicitly; the chart only grants it a minimal Role for Reloader's internal meta-info ConfigMap (and leader-election in HA). 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 32d129c9..b2c6186e 100644 --- a/deployments/kubernetes/chart/reloader/templates/_helpers.tpl +++ b/deployments/kubernetes/chart/reloader/templates/_helpers.tpl @@ -91,7 +91,10 @@ Create the namespace selector if it does not watch globally {{/* Namespaces to watch in scoped mode: exactly the user-supplied reloader.namespaces, trimmed, de-duped and sorted. The release namespace is intentionally NOT added here -— Reloader watches only what the user asked for (an empty result means global mode). +— Reloader watches only what the user asked for. An empty result is not necessarily +global mode: with watchGlobally=false it becomes single-namespace mode (the release +namespace, injected via --namespaces by reloader-effectiveNamespaces-csv); only with +watchGlobally=true does empty mean watch-all. Returns a JSON-encoded list; consumers use mustFromJson to iterate. */}} {{- define "reloader-watchNamespaces" -}} @@ -116,13 +119,45 @@ Comma-joined form of reloader-watchNamespaces, for the --namespaces CLI flag. {{- include "reloader-watchNamespaces" . | mustFromJson | join "," -}} {{- end -}} +{{/* +The effective watched namespaces for the --namespaces CLI flag — the single +chart-side source of truth for watch scope, so the binary never has to fall back +to the KUBERNETES_NAMESPACE env: + - scoped mode -> the cleaned reloader.namespaces list + - single-namespace mode -> the release namespace (watchGlobally=false, no list) + - global mode -> empty (no --namespaces flag; watch all) +Returns a comma-joined string ("" in global mode). +*/}} +{{- define "reloader-effectiveNamespaces-csv" -}} +{{- $watch := include "reloader-watchNamespaces" . | mustFromJson -}} +{{- if $watch -}} +{{- $watch | join "," -}} +{{- else if not .Values.reloader.watchGlobally -}} +{{- .Values.namespace | default .Release.Namespace -}} +{{- end -}} +{{- end -}} + +{{/* +Whether Reloader runs in scoped mode. This is the single source of truth for the +scoped-vs-global decision: it is true only when the cleaned watch list +(reloader-watchNamespaces) is non-empty. Gate on this rather than the raw +.Values.reloader.namespaces, which is truthy even for values like " , " that trim +to an empty list (those must fall through to global/single-namespace mode). +Returns "true" (truthy) or "" (falsy). +*/}} +{{- define "reloader-isScoped" -}} +{{- if include "reloader-watchNamespaces" . | mustFromJson -}} +true +{{- end -}} +{{- end -}} + {{/* Fails the render on an inconsistent namespace configuration: reloader.namespaces (scoped mode) requires reloader.watchGlobally=false. Included from deployment.yaml so it is validated once regardless of which templates render. */}} {{- define "reloader-validate-namespaces" -}} -{{- if and .Values.reloader.watchGlobally .Values.reloader.namespaces -}} +{{- if and .Values.reloader.watchGlobally (include "reloader-isScoped" .) -}} {{- fail "reloader.namespaces is set but reloader.watchGlobally is true; set reloader.watchGlobally=false to use scoped namespace mode." -}} {{- end -}} {{- end -}} @@ -131,9 +166,9 @@ so it is validated once regardless of which templates render. RBAC rules Reloader needs in its own (release) namespace, independent of the watched namespaces. Reloader publishes an internal meta-info ConfigMap there in every mode, so configmap write access is always granted. In scoped mode the -release namespace is not covered by the watch RBAC, so leader-election events -(and leases under HA) are granted here too; in global/single mode those are -already covered by the ClusterRole or the single-namespace Role. +release namespace is not covered by the watch RBAC, so under HA the leader-election +leases and the events it emits are granted here too; in global/single mode those +are already covered by the ClusterRole or the single-namespace Role. Expects the root context ($) as its argument. */}} {{- define "reloader-release-rules" }} @@ -146,7 +181,15 @@ Expects the root context ($) as its argument. - create - update - patch -{{- if .Values.reloader.namespaces }} +{{- if and (include "reloader-isScoped" .) .Values.reloader.enableHA }} + - apiGroups: + - "coordination.k8s.io" + resources: + - leases + verbs: + - create + - get + - update - apiGroups: - "" - "events.k8s.io" @@ -156,16 +199,6 @@ Expects the root context ($) as its argument. - create - patch - update -{{- if .Values.reloader.enableHA }} - - apiGroups: - - "coordination.k8s.io" - resources: - - leases - verbs: - - create - - get - - update -{{- end }} {{- end }} {{- end -}} diff --git a/deployments/kubernetes/chart/reloader/templates/deployment.yaml b/deployments/kubernetes/chart/reloader/templates/deployment.yaml index ff43894a..d7b9a673 100644 --- a/deployments/kubernetes/chart/reloader/templates/deployment.yaml +++ b/deployments/kubernetes/chart/reloader/templates/deployment.yaml @@ -144,12 +144,6 @@ spec: fieldRef: fieldPath: {{ $value | quote}} {{- end }} - {{- end }} - {{- if and (eq .Values.reloader.watchGlobally false) (not .Values.reloader.namespaces) }} - - name: KUBERNETES_NAMESPACE - valueFrom: - fieldRef: - fieldPath: metadata.namespace {{- end }} - name: RELOADER_NAMESPACE @@ -216,7 +210,7 @@ spec: {{- . | toYaml | nindent 10 }} {{- end }} {{- end }} - {{- 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)}} + {{- if or (.Values.reloader.logFormat) (.Values.reloader.logLevel) (.Values.reloader.ignoreSecrets) (and .Values.reloader.ignoreNamespaces .Values.reloader.watchGlobally) (include "reloader-effectiveNamespaces-csv" .) (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 }}" @@ -237,8 +231,8 @@ spec: {{- else if .Values.reloader.ignoreCronJobs }} - "--ignored-workload-types=cronjobs" {{- end }} - {{- if .Values.reloader.namespaces }} - - "--namespaces={{ include "reloader-watchNamespaces-csv" . }}" + {{- if (include "reloader-effectiveNamespaces-csv" .) }} + - "--namespaces={{ include "reloader-effectiveNamespaces-csv" . }}" {{- end }} {{- if and .Values.reloader.ignoreNamespaces .Values.reloader.watchGlobally }} - "--namespaces-to-ignore={{ .Values.reloader.ignoreNamespaces }}" diff --git a/deployments/kubernetes/chart/reloader/templates/role.yaml b/deployments/kubernetes/chart/reloader/templates/role.yaml index 0454eadf..4333e73c 100644 --- a/deployments/kubernetes/chart/reloader/templates/role.yaml +++ b/deployments/kubernetes/chart/reloader/templates/role.yaml @@ -3,7 +3,7 @@ {{- if not (.Capabilities.APIVersions.Has "rbac.authorization.k8s.io/v1") }} {{- $apiVersion = "rbac.authorization.k8s.io/v1beta1" }} {{- end }} -{{- if .Values.reloader.namespaces }} +{{- if (include "reloader-isScoped" .) }} {{- range $ns := (include "reloader-watchNamespaces" . | mustFromJson) }} apiVersion: {{ $apiVersion }} kind: Role diff --git a/deployments/kubernetes/chart/reloader/templates/rolebinding.yaml b/deployments/kubernetes/chart/reloader/templates/rolebinding.yaml index 7d73b182..7965973b 100644 --- a/deployments/kubernetes/chart/reloader/templates/rolebinding.yaml +++ b/deployments/kubernetes/chart/reloader/templates/rolebinding.yaml @@ -3,7 +3,7 @@ {{- if not (.Capabilities.APIVersions.Has "rbac.authorization.k8s.io/v1") }} {{- $apiVersion = "rbac.authorization.k8s.io/v1beta1" }} {{- end }} -{{- if .Values.reloader.namespaces }} +{{- if (include "reloader-isScoped" .) }} {{- range $ns := (include "reloader-watchNamespaces" . | mustFromJson) }} apiVersion: {{ $apiVersion }} kind: RoleBinding diff --git a/deployments/kubernetes/chart/reloader/tests/namespaces_flags_test.yaml b/deployments/kubernetes/chart/reloader/tests/namespaces_flags_test.yaml new file mode 100644 index 00000000..04579b16 --- /dev/null +++ b/deployments/kubernetes/chart/reloader/tests/namespaces_flags_test.yaml @@ -0,0 +1,134 @@ +suite: Namespace watch flags & validation +templates: + - deployment.yaml +release: + name: reloader + namespace: reloader-ns +tests: + # ---------------------------------------------------------------- scoped mode + - it: scoped mode passes the watched namespaces via --namespaces + set: + reloader: + watchGlobally: false + namespaces: + - team-a + - team-b + asserts: + - contains: + path: spec.template.spec.containers[0].args + content: "--namespaces=team-a,team-b" + + - it: namespaces are de-duplicated and sorted for the --namespaces flag + set: + reloader: + watchGlobally: false + namespaces: + - team-b + - team-a + - team-a + asserts: + - contains: + path: spec.template.spec.containers[0].args + content: "--namespaces=team-a,team-b" + + - it: a comma-separated string value is accepted for reloader.namespaces + set: + reloader: + watchGlobally: false + namespaces: "team-a,team-b" + asserts: + - contains: + path: spec.template.spec.containers[0].args + content: "--namespaces=team-a,team-b" + + # ----------------------------------------------------------- single-ns mode + - it: single-namespace mode passes the release namespace via --namespaces + set: + reloader: + watchGlobally: false + asserts: + - contains: + path: spec.template.spec.containers[0].args + content: "--namespaces=reloader-ns" + + - it: whitespace-only namespaces fall through to single-namespace mode + set: + reloader: + watchGlobally: false + namespaces: + - " " + - " " + asserts: + - contains: + path: spec.template.spec.containers[0].args + content: "--namespaces=reloader-ns" + + - it: KUBERNETES_NAMESPACE env is no longer injected in single-namespace mode + set: + reloader: + watchGlobally: false + asserts: + - notContains: + path: spec.template.spec.containers[0].env + content: + name: KUBERNETES_NAMESPACE + any: true + + # ---------------------------------------------------------------- global mode + - it: global mode does not pass a --namespaces flag + set: + reloader: + watchGlobally: true + logLevel: info + asserts: + - notContains: + path: spec.template.spec.containers[0].args + content: "--namespaces=reloader-ns" + - notContains: + path: spec.template.spec.containers[0].args + content: "--namespaces=team-a" + + - it: namespaces-to-ignore is only emitted in global mode + set: + reloader: + watchGlobally: true + ignoreNamespaces: "kube-system" + asserts: + - contains: + path: spec.template.spec.containers[0].args + content: "--namespaces-to-ignore=kube-system" + + - it: namespaces-to-ignore is dropped in single-namespace mode + set: + reloader: + watchGlobally: false + ignoreNamespaces: "kube-system" + asserts: + - notContains: + path: spec.template.spec.containers[0].args + content: "--namespaces-to-ignore=kube-system" + + # ------------------------------------------------------------- validation + - it: fails to render when reloader.namespaces is set but watchGlobally is true + set: + reloader: + watchGlobally: true + namespaces: + - team-a + asserts: + - failedTemplate: + errorMessage: "reloader.namespaces is set but reloader.watchGlobally is true; set reloader.watchGlobally=false to use scoped namespace mode." + + - it: does not fail validation for whitespace-only namespaces with watchGlobally true + set: + reloader: + watchGlobally: true + namespaces: + - " " + asserts: + # whitespace trims to an empty watch list, so this is global mode, not scoped -> renders fine + - hasDocuments: + count: 1 + - notContains: + path: spec.template.spec.containers[0].args + content: "--namespaces=reloader-ns" diff --git a/deployments/kubernetes/chart/reloader/tests/namespaces_rbac_test.yaml b/deployments/kubernetes/chart/reloader/tests/namespaces_rbac_test.yaml new file mode 100644 index 00000000..0585cdeb --- /dev/null +++ b/deployments/kubernetes/chart/reloader/tests/namespaces_rbac_test.yaml @@ -0,0 +1,234 @@ +suite: Namespace-scoped RBAC +templates: + - role.yaml + - rolebinding.yaml + - clusterrole.yaml +release: + name: reloader + namespace: reloader-ns +tests: + # ---------------------------------------------------------------- scoped mode + - it: scoped mode renders a Role + RoleBinding per watched namespace plus the metadata role, and no ClusterRole + set: + reloader: + watchGlobally: false + namespaces: + - team-a + - team-b + asserts: + # 2 watched Roles + 1 metadata Role + - hasDocuments: + count: 3 + template: role.yaml + - hasDocuments: + count: 3 + template: rolebinding.yaml + - hasDocuments: + count: 0 + template: clusterrole.yaml + + - it: scoped mode grants the namespaced workload rules in a watched namespace + set: + reloader: + watchGlobally: false + namespaces: + - team-a + - team-b + template: role.yaml + documentSelector: + path: metadata.namespace + value: team-a + asserts: + - isKind: + of: Role + - equal: + path: rules[0].apiGroups[0] + value: "" + - contains: + path: rules[0].resources + content: secrets + - contains: + path: rules[0].resources + content: configmaps + - contains: + path: rules + content: + apiGroups: + - "apps" + resources: + - deployments + - daemonsets + - statefulsets + verbs: + - list + - get + - watch + - update + - patch + + - it: scoped RoleBinding in a watched namespace binds the release ServiceAccount to the namespaced Role + set: + reloader: + watchGlobally: false + namespaces: + - team-a + - team-b + template: rolebinding.yaml + documentSelector: + path: metadata.namespace + value: team-b + asserts: + - isKind: + of: RoleBinding + - equal: + path: roleRef.name + value: reloader-reloader-role + - equal: + path: subjects[0].namespace + value: reloader-ns + + # ------------------------------------------------------------- metadata role + - it: metadata role is created in the release namespace with configmap write in scoped mode + set: + reloader: + watchGlobally: false + namespaces: + - team-a + template: role.yaml + documentSelector: + path: metadata.name + value: reloader-reloader-metadata-role + asserts: + - equal: + path: metadata.namespace + value: reloader-ns + - contains: + path: rules[0].resources + content: configmaps + - contains: + path: rules[0].verbs + content: create + + - it: metadata role also grants leases and events in the release namespace under HA + scoped mode + set: + reloader: + watchGlobally: false + enableHA: true + namespaces: + - team-a + template: role.yaml + documentSelector: + path: metadata.name + value: reloader-reloader-metadata-role + asserts: + - contains: + path: rules + content: + apiGroups: + - "coordination.k8s.io" + resources: + - leases + verbs: + - create + - get + - update + + - it: metadata role does NOT grant leases when HA is disabled in scoped mode + set: + reloader: + watchGlobally: false + enableHA: false + namespaces: + - team-a + template: role.yaml + documentSelector: + path: metadata.name + value: reloader-reloader-metadata-role + asserts: + - notContains: + path: rules + content: + apiGroups: + - "coordination.k8s.io" + resources: + - leases + verbs: + - create + - get + - update + + # ----------------------------------------------------------- single-ns mode + - it: single-namespace mode (watchGlobally=false, no list) renders one Role in the release namespace and no ClusterRole + set: + reloader: + watchGlobally: false + asserts: + # 1 single Role + 1 metadata Role + - hasDocuments: + count: 2 + template: role.yaml + - hasDocuments: + count: 0 + template: clusterrole.yaml + - equal: + path: metadata.namespace + value: reloader-ns + template: role.yaml + documentIndex: 0 + + # ---------------------------------------------------------------- global mode + - it: global mode renders a ClusterRole and only the metadata Role (no watched Roles) + set: + reloader: + watchGlobally: true + asserts: + - hasDocuments: + count: 1 + template: clusterrole.yaml + - isKind: + of: ClusterRole + template: clusterrole.yaml + # only the metadata role remains from role.yaml + - hasDocuments: + count: 1 + template: role.yaml + - equal: + path: metadata.name + value: reloader-reloader-metadata-role + template: role.yaml + documentIndex: 0 + + # -------------------------------------------------------- input normalization + - it: whitespace-only namespaces fall through to single-namespace mode (no watched Roles) + set: + reloader: + watchGlobally: false + namespaces: + - " " + - " " + asserts: + # cleaned list is empty -> single Role + metadata Role, both in release ns + - hasDocuments: + count: 2 + template: role.yaml + - equal: + path: metadata.namespace + value: reloader-ns + template: role.yaml + documentIndex: 0 + + - it: rbac disabled renders no Roles, RoleBindings, or ClusterRole + set: + reloader: + watchGlobally: false + rbac: + enabled: false + namespaces: + - team-a + asserts: + - hasDocuments: + count: 0 + template: role.yaml + - hasDocuments: + count: 0 + template: rolebinding.yaml diff --git a/internal/pkg/config/validation.go b/internal/pkg/config/validation.go index b3d2695b..e3f72543 100644 --- a/internal/pkg/config/validation.go +++ b/internal/pkg/config/validation.go @@ -5,6 +5,7 @@ import ( "strings" "k8s.io/apimachinery/pkg/labels" + apivalidation "k8s.io/apimachinery/pkg/util/validation" "github.com/stakater/Reloader/internal/pkg/workload" ) @@ -97,6 +98,19 @@ func (c *Config) Validate() error { ) } + // Watched namespaces must be valid DNS-1123 labels; an invalid entry would + // otherwise fail deep inside the controller-runtime cache with an opaque error. + for _, ns := range c.WatchedNamespaces { + if msgs := apivalidation.IsDNS1123Label(ns); len(msgs) > 0 { + errs = append( + errs, ValidationError{ + Field: "WatchedNamespaces", + Message: fmt.Sprintf("invalid namespace %q: %s", ns, strings.Join(msgs, "; ")), + }, + ) + } + } + c.IgnoredResources = normalizeToLower(c.IgnoredResources) // Normalize ignored workloads to canonical Kind values (e.g., "cronjobs" -> "CronJob") diff --git a/internal/pkg/config/validation_test.go b/internal/pkg/config/validation_test.go index 52dc6f00..f4772c44 100644 --- a/internal/pkg/config/validation_test.go +++ b/internal/pkg/config/validation_test.go @@ -194,6 +194,40 @@ func TestConfig_Validate_InvalidIgnoredWorkload(t *testing.T) { } } +func TestConfig_Validate_WatchedNamespaces(t *testing.T) { + tests := []struct { + name string + namespaces []string + wantErr bool + }{ + {"nil is valid (global mode)", nil, false}, + {"empty is valid (global mode)", []string{}, false}, + {"single valid label", []string{"team-a"}, false}, + {"multiple valid labels", []string{"team-a", "team-b", "kube-system"}, false}, + {"uppercase is invalid", []string{"Team-A"}, true}, + {"underscore is invalid", []string{"team_a"}, true}, + {"trailing dash is invalid", []string{"team-"}, true}, + {"one invalid among valid", []string{"team-a", "Bad_NS!"}, true}, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + cfg := NewDefault() + cfg.WatchedNamespaces = tt.namespaces + + err := cfg.Validate() + if (err != nil) != tt.wantErr { + t.Fatalf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr && !strings.Contains(err.Error(), "WatchedNamespaces") { + t.Errorf("error should mention WatchedNamespaces, got: %v", err) + } + }, + ) + } +} + func TestConfig_Validate_MultipleErrors(t *testing.T) { cfg := NewDefault() cfg.ReloadStrategy = "invalid" diff --git a/test/e2e/flags/watch_namespaces_test.go b/test/e2e/flags/watch_namespaces_test.go index bcfbd6cb..7f2a1031 100644 --- a/test/e2e/flags/watch_namespaces_test.go +++ b/test/e2e/flags/watch_namespaces_test.go @@ -113,3 +113,93 @@ var _ = Describe("Watch Namespaces (scoped mode) Flag Tests", Serial, func() { Expect(reloaded).To(BeFalse(), "Deployment in an unwatched namespace should NOT reload") }) }) + +var _ = Describe("Watch Multiple Namespaces (scoped mode) Flag Tests", Serial, func() { + var ( + deploymentName string + configMapName string + watchedA string + watchedB string + unwatchedNS string + adapter *utils.DeploymentAdapter + ) + + BeforeEach(func() { + deploymentName = utils.RandName("deploy") + configMapName = utils.RandName("cm") + watchedA = "watched-a-" + utils.RandName("ns") + watchedB = "watched-b-" + utils.RandName("ns") + unwatchedNS = "unwatched-" + utils.RandName("ns") + adapter = utils.NewDeploymentAdapter(kubeClient) + + // Both watched namespaces must exist before install: in scoped mode the + // chart creates a Role/RoleBinding in each. + Expect(utils.CreateNamespace(ctx, kubeClient, watchedA)).To(Succeed()) + Expect(utils.CreateNamespace(ctx, kubeClient, watchedB)).To(Succeed()) + Expect(utils.CreateNamespace(ctx, kubeClient, unwatchedNS)).To(Succeed()) + + err := deployReloaderWithFlags(map[string]string{ + "reloader.watchGlobally": "false", + "reloader.namespaces": fmt.Sprintf("{%s,%s}", watchedA, watchedB), + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(waitForReloaderReady()).To(Succeed()) + }) + + AfterEach(func() { + for _, ns := range []string{watchedA, watchedB, unwatchedNS} { + _ = utils.DeleteDeployment(ctx, kubeClient, ns, deploymentName) + _ = utils.DeleteConfigMap(ctx, kubeClient, ns, configMapName) + } + _ = undeployReloader() + for _, ns := range []string{watchedA, watchedB, unwatchedNS} { + _ = utils.DeleteNamespace(ctx, kubeClient, ns) + } + }) + + It("should reload workloads across all watched namespaces but not an unwatched one", func() { + // Set up an annotated Deployment + ConfigMap in every namespace (both + // watched ones and the unwatched control). + allNS := []string{watchedA, watchedB, unwatchedNS} + for _, ns := range allNS { + By("Creating a ConfigMap and Deployment in " + ns) + _, err := utils.CreateConfigMap(ctx, kubeClient, ns, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + _, err = utils.CreateDeployment(ctx, kubeClient, ns, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithAnnotations(utils.BuildAutoTrueAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(adapter.WaitReady(ctx, ns, deploymentName, utils.WorkloadReadyTimeout)).To(Succeed()) + } + + // Capture reload baselines before triggering, then update every ConfigMap. + priorReload := map[string]string{} + for _, ns := range allNS { + pv, err := adapter.GetPodTemplateAnnotation(ctx, ns, deploymentName, utils.AnnotationLastReloadedFrom) + Expect(err).NotTo(HaveOccurred()) + priorReload[ns] = pv + } + for _, ns := range allNS { + By("Updating the ConfigMap in " + ns) + Expect(utils.UpdateConfigMap(ctx, kubeClient, ns, configMapName, + map[string]string{"key": "updated"})).To(Succeed()) + } + + By("Verifying workloads in BOTH watched namespaces reloaded") + for _, ns := range []string{watchedA, watchedB} { + reloaded, err := adapter.WaitReloadedFrom(ctx, ns, deploymentName, + utils.AnnotationLastReloadedFrom, priorReload[ns], utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment in watched namespace %s should reload", ns) + } + + By("Verifying the workload in the unwatched namespace did NOT reload") + reloaded, err := adapter.WaitReloadedFrom(ctx, unwatchedNS, deploymentName, + utils.AnnotationLastReloadedFrom, priorReload[unwatchedNS], utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "Deployment in unwatched namespace %s should NOT reload", unwatchedNS) + }) +})