Merge branch 'feat/update-chart-v2' into feat/add-reloader-enterprise-in-v2-chart

This commit is contained in:
Safwan
2026-07-17 12:10:20 +05:00
10 changed files with 561 additions and 28 deletions
@@ -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` |
@@ -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 -}}
@@ -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 }}"
@@ -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
@@ -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
@@ -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"
@@ -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
+14
View File
@@ -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")
+34
View File
@@ -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"
+90
View File
@@ -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)
})
})