diff --git a/helm/kagent/templates/_helpers.tpl b/helm/kagent/templates/_helpers.tpl index 05d2f7d0f2..a3491fbcec 100644 --- a/helm/kagent/templates/_helpers.tpl +++ b/helm/kagent/templates/_helpers.tpl @@ -51,17 +51,51 @@ Allows overriding it for multi-namespace deployments in combined charts. {{- end }} {{/* -Watch namespaces - transforms list of namespaces cached by the controller into comma-separated string. -Precedence: controller.watchNamespaces (explicit override) > rbac.namespaces > empty (watch all). +Watch namespaces - transforms the list of namespaces cached by the controller into a comma-separated string. +controller.watchNamespaces is an explicit override; otherwise the watch scope is the resolved RBAC scope +(kagent.rbacNamespaces), so the controller never watches a namespace its Roles do not cover and never +holds a cluster-wide cache when RBAC is namespaced. An explicit rbac.namespaces: [] therefore also +clears the watch scope back to cluster-wide. */}} {{- define "kagent.watchNamespaces" -}} {{- if .Values.controller.watchNamespaces -}} {{- .Values.controller.watchNamespaces | uniq | join "," -}} -{{- else if and .Values.rbac .Values.rbac.namespaces -}} - {{- .Values.rbac.namespaces | uniq | join "," -}} +{{- else -}} + {{- include "kagent.rbacNamespaces" . | fromJsonArray | join "," -}} {{- end -}} {{- end -}} +{{/* +The resolved RBAC scope, as a JSON list so callers can range over it. +Precedence: rbac.namespaces > global.watchNamespaces > empty (cluster-scoped). +The global is a fallback, not an override: a values file that sets rbac.namespaces +renders exactly what it rendered before the global existed. + +hasKey, not coalesce: an explicit `rbac.namespaces: []` means "cluster-scoped", +and coalesce would skip it as empty -- silently namespacing an install that +asked not to be. A present key always wins, even empty. + +controller.watchNamespaces joins the scope: the controller needs a Role in +every namespace it watches, so a watch entry outside the RBAC list would be a +permanent Forbidden loop. kagent.rbac.validate rejects that mix for an explicit +rbac.namespaces; under the global the watch entries are folded in instead. + +The install namespace is appended only on the global path. The global is a +shared signal an umbrella may aim at other charts entirely; failing this +chart's render because that list omits its namespace would brick an install +the value was never about. An explicit rbac.namespaces keeps the hard fail -- +there the operator is talking about this chart. +*/}} +{{- define "kagent.rbacNamespaces" -}} +{{- $scope := list -}} +{{- if and .Values.rbac (hasKey .Values.rbac "namespaces") -}} +{{- $scope = .Values.rbac.namespaces | default list -}} +{{- else if ((.Values.global).watchNamespaces) -}} +{{- $scope = concat (.Values.global).watchNamespaces (.Values.controller.watchNamespaces | default list) (list (include "kagent.namespace" .)) -}} +{{- end -}} +{{- $scope | uniq | sortAlpha | toJson -}} +{{- end -}} + {{/* Guards on the rbac block */}} @@ -69,11 +103,23 @@ Guards on the rbac block {{- if and .Values.rbac (hasKey .Values.rbac "clusterScoped") -}} {{- fail "rbac.clusterScoped has been removed. Leave rbac.namespaces empty for cluster-scoped RBAC, or set rbac.namespaces=[, ...] for namespaced RBAC." -}} {{- end -}} +{{- $resolved := include "kagent.rbacNamespaces" . | fromJsonArray -}} {{- if and .Values.rbac .Values.rbac.namespaces -}} {{- $installNs := include "kagent.namespace" . -}} {{- if not (has $installNs .Values.rbac.namespaces) -}} {{- fail (printf "rbac.namespaces is set but does not include the install namespace %q" $installNs) -}} {{- end -}} +{{/* +A watch wider than the RBAC scope is never valid: the controller lists and +watches namespaces its Roles do not cover, and every reconcile there returns +Forbidden at runtime with only a log line to show for it. Narrower is fine -- +an operator may grant Roles broadly and watch a subset to keep the cache small. +*/}} +{{- range $ns := (.Values.controller.watchNamespaces | default list) -}} +{{- if not (has $ns $.Values.rbac.namespaces) -}} +{{- fail (printf "controller.watchNamespaces includes %q but rbac.namespaces does not. The controller would watch a namespace it has no Role in, and every list/watch there returns Forbidden at runtime. Add %q to rbac.namespaces, or remove it from controller.watchNamespaces. Prefer setting only global.watchNamespaces, which scopes RBAC and the watch together." $ns $ns) -}} +{{- end -}} +{{- end -}} {{- end -}} {{- end -}} @@ -235,7 +281,8 @@ Bundled PostgreSQL image - constructs the full image reference from registry/rep */}} {{- define "kagent.postgresql.image" -}} {{- $pg := .Values.database.postgres.bundled -}} -{{- $parts := compact (list $pg.image.registry $pg.image.repository $pg.image.name) -}} +{{- $registry := default $pg.image.registry (include "kagent.globalImageRegistry" .) -}} +{{- $parts := compact (list $registry $pg.image.repository $pg.image.name) -}} {{- printf "%s:%s" (join "/" $parts) $pg.image.tag -}} {{- end -}} @@ -266,11 +313,32 @@ Controller Service host:port for nginx upstream (no scheme). imagePullSecrets from global values (for subchart usage). Reads .Values.global.imagePullSecrets set by the parent chart. */}} +{{/* +imagePullSecrets for a pod spec: a component-local list (or the chart-level +one) merged (union) with global.imagePullSecrets. One definition, called from +every pod spec -- the merge written twice drifts, and the pod that misses a +semantics change fails ImagePullBackOff only in the air-gap case the global +exists for. + +Usage: {{ include "kagent.imagePullSecrets" (dict "root" $ "local" .Values.controller.imagePullSecrets) }} +*/}} +{{/* +imagePullPolicy for a container: the component's own value, then the chart-level +imagePullPolicy, then global.imagePullPolicy, then IfNotPresent. One definition so +the fallback chain cannot drift between pods. + +Usage: {{ include "kagent.imagePullPolicy" (dict "root" $ "local" .Values.controller.image.pullPolicy) }} +*/}} +{{- define "kagent.imagePullPolicy" -}} +{{- .local | default .root.Values.imagePullPolicy | default ((.root.Values.global).imagePullPolicy) | default "IfNotPresent" -}} +{{- end -}} + {{- define "kagent.imagePullSecrets" -}} -{{- $global := ((.Values.global).imagePullSecrets) | default list -}} -{{- if $global -}} +{{- $local := .local | default .root.Values.imagePullSecrets | default list -}} +{{- $merged := concat $local (((.root.Values.global).imagePullSecrets) | default list) | uniq -}} +{{- if $merged -}} imagePullSecrets: -{{- toYaml $global | nindent 2 }} +{{- toYaml $merged | nindent 2 }} {{- end -}} {{- end -}} @@ -331,3 +399,63 @@ forwarded to kagent's branded /login page. Redirecting to login... {{- end -}} + +{{/* +The controller container image. Builds the image root from controller.image and +resolves it through kagent.images.image, so the deployment carries one short +call. The top-level tag wins over the component tag, as it always has. +*/}} +{{- define "kagent.controllerImage" -}} +{{- $root := dict "registry" (.Values.controller.image.registry | default .Values.registry) "repository" .Values.controller.image.repository "tag" (coalesce .Values.tag .Values.controller.image.tag .Chart.Version) -}} +{{- $global := dict "imageRegistry" (include "kagent.globalImageRegistry" .) -}} +{{- include "kagent.images.image" (dict "imageRoot" $root "global" $global) -}} +{{- end -}} + +{{/* +global.imageRegistry, normalized. A trailing slash is an easy value to ship +("mirror.example/") and every consumer joins the registry onto a path with its +own "/", so the raw value would render an image reference with a double slash +that fails at pull time. Every template that reads the global goes through +this helper so the tolerance is uniform across the chart. +*/}} +{{- define "kagent.globalImageRegistry" -}} +{{- ((.Values.global).imageRegistry) | default "" | trimSuffix "/" -}} +{{- end -}} + +{{/* +Rewrite a full image reference onto global.imageRegistry, for values that carry +a whole reference in one string rather than registry/repository/tag keys. +Follows the container runtime's rule for deciding whether the first path +segment is a registry: it is one only when it contains a dot or a colon, is +exactly "localhost", or contains an uppercase letter (a repository path is +lowercase-only, so an uppercase segment can only be a host). A host-carrying +reference has that segment replaced so the mirror sees a stable path; a bare +Docker Hub-style name is prefixed instead. When global.imageRegistry is unset +the reference passes through unchanged. +Call with (dict "root" $ "image" ). +*/}} +{{- define "kagent.mirroredImage" -}} +{{- $ref := .image -}} +{{- $mirror := include "kagent.globalImageRegistry" .root -}} +{{- if and $mirror $ref -}} + {{- $parts := splitList "/" $ref -}} + {{- $first := first $parts -}} + {{- if and (gt (len $parts) 1) (or (contains "." $first) (contains ":" $first) (eq $first "localhost") (ne $first ($first | lower))) -}} + {{- printf "%s/%s" $mirror (join "/" (rest $parts)) -}} + {{- else -}} + {{- printf "%s/%s" $mirror $ref -}} + {{- end -}} +{{- else -}} + {{- $ref -}} +{{- end -}} +{{- end -}} + +{{/* +The ui container image. Same tag precedence as the controller: the top-level +tag wins over the component tag. +*/}} +{{- define "kagent.uiImage" -}} +{{- $root := dict "registry" (.Values.ui.image.registry | default .Values.registry) "repository" .Values.ui.image.repository "tag" (coalesce .Values.tag .Values.ui.image.tag .Chart.Version) -}} +{{- $global := dict "imageRegistry" (include "kagent.globalImageRegistry" .) -}} +{{- include "kagent.images.image" (dict "imageRoot" $root "global" $global) -}} +{{- end -}} diff --git a/helm/kagent/templates/_images.tpl b/helm/kagent/templates/_images.tpl new file mode 100644 index 0000000000..1c47d4cd8f --- /dev/null +++ b/helm/kagent/templates/_images.tpl @@ -0,0 +1,32 @@ +{{/* +Copyright Broadcom, Inc. All Rights Reserved. +SPDX-License-Identifier: APACHE-2.0 +*/}} + +{{/* vim: set filetype=mustache: */}} +{{/* +Return the proper image name. +If image tag and digest are not defined, termination fallbacks to chart appVersion. +{{ include "kagent.images.image" ( dict "imageRoot" .Values.path.to.the.image "global" .Values.global "chart" .Chart ) }} +*/}} +{{- define "kagent.images.image" -}} +{{- $registryName := default .imageRoot.registry ((.global).imageRegistry) -}} +{{- $repositoryName := .imageRoot.repository -}} +{{- $separator := ":" -}} +{{- $termination := .imageRoot.tag | toString -}} + +{{- if not .imageRoot.tag }} + {{- if .chart }} + {{- $termination = .chart.AppVersion | toString -}} + {{- end -}} +{{- end -}} +{{- if .imageRoot.digest }} + {{- $separator = "@" -}} + {{- $termination = .imageRoot.digest | toString -}} +{{- end -}} +{{- if $registryName }} + {{- printf "%s/%s%s%s" $registryName $repositoryName $separator $termination -}} +{{- else -}} + {{- printf "%s%s%s" $repositoryName $separator $termination -}} +{{- end -}} +{{- end -}} diff --git a/helm/kagent/templates/controller-deployment.yaml b/helm/kagent/templates/controller-deployment.yaml index a07ff963c1..9de69c00b1 100644 --- a/helm/kagent/templates/controller-deployment.yaml +++ b/helm/kagent/templates/controller-deployment.yaml @@ -29,10 +29,7 @@ spec: {{- $podLabels := mergeOverwrite (dict) (.Values.podLabels | default dict) (.Values.controller.podLabels | default dict) (include "kagent.controller.selectorLabels" . | fromYaml) }} {{- toYaml $podLabels | nindent 8 }} spec: - {{- with .Values.controller.imagePullSecrets | default .Values.imagePullSecrets }} - imagePullSecrets: - {{- toYaml . | nindent 8 }} - {{- end }} + {{- with include "kagent.imagePullSecrets" (dict "root" . "local" .Values.controller.imagePullSecrets) }}{{- . | nindent 6 }}{{- end }} {{- with (.Values.controller.podSecurityContext | default .Values.podSecurityContext) }} securityContext: {{- toYaml . | nindent 8 }} @@ -80,8 +77,8 @@ spec: {{- end }} containers: - name: controller - image: "{{ .Values.controller.image.registry | default .Values.registry }}/{{ .Values.controller.image.repository }}:{{ coalesce .Values.tag .Values.controller.image.tag .Chart.Version }}" - imagePullPolicy: {{ .Values.controller.image.pullPolicy | default .Values.imagePullPolicy }} + image: {{ include "kagent.controllerImage" . | quote }} + imagePullPolicy: {{ include "kagent.imagePullPolicy" (dict "root" . "local" .Values.controller.image.pullPolicy) }} env: - name: KAGENT_NAMESPACE valueFrom: diff --git a/helm/kagent/templates/postgresql.yaml b/helm/kagent/templates/postgresql.yaml index 42a7327734..0f735c1aa2 100644 --- a/helm/kagent/templates/postgresql.yaml +++ b/helm/kagent/templates/postgresql.yaml @@ -53,7 +53,7 @@ spec: {{- $podLabels := mergeOverwrite (dict) (.Values.podLabels | default dict) ($pg.podLabels | default dict) (include "kagent.selectorLabels" . | fromYaml) (dict "app.kubernetes.io/component" "database") }} {{- toYaml $podLabels | nindent 8 }} spec: - {{- include "kagent.imagePullSecrets" $ | nindent 6 }} + {{- with include "kagent.imagePullSecrets" (dict "root" $) }}{{- . | nindent 6 }}{{- end }} serviceAccountName: {{ $fullname }} {{- with $pg.podSecurityContext }} securityContext: @@ -74,7 +74,7 @@ spec: containers: - name: postgresql image: {{ include "kagent.postgresql.image" . }} - imagePullPolicy: {{ $pg.image.pullPolicy }} + imagePullPolicy: {{ include "kagent.imagePullPolicy" (dict "root" $ "local" $pg.image.pullPolicy) }} {{- with $pg.securityContext }} securityContext: {{- toYaml . | nindent 12 }} diff --git a/helm/kagent/templates/rbac/getter-role.yaml b/helm/kagent/templates/rbac/getter-role.yaml index dbf39146d1..32c3ad6f24 100644 --- a/helm/kagent/templates/rbac/getter-role.yaml +++ b/helm/kagent/templates/rbac/getter-role.yaml @@ -98,8 +98,9 @@ {{- end -}} {{- include "kagent.rbac.validate" . -}} -{{- if .Values.rbac.namespaces }} -{{- range $namespace := (.Values.rbac.namespaces | uniq | sortAlpha) }} +{{- $rbacNamespaces := include "kagent.rbacNamespaces" . | fromJsonArray }} +{{- if $rbacNamespaces }} +{{- range $namespace := $rbacNamespaces }} --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role diff --git a/helm/kagent/templates/rbac/getter-rolebinding.yaml b/helm/kagent/templates/rbac/getter-rolebinding.yaml index 9d143f72d0..e8e463d144 100644 --- a/helm/kagent/templates/rbac/getter-rolebinding.yaml +++ b/helm/kagent/templates/rbac/getter-rolebinding.yaml @@ -1,6 +1,7 @@ {{- include "kagent.rbac.validate" . -}} -{{- if .Values.rbac.namespaces }} -{{- range $namespace := .Values.rbac.namespaces | uniq | sortAlpha }} +{{- $rbacNamespaces := include "kagent.rbacNamespaces" . | fromJsonArray }} +{{- if $rbacNamespaces }} +{{- range $namespace := $rbacNamespaces }} --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding diff --git a/helm/kagent/templates/rbac/writer-role.yaml b/helm/kagent/templates/rbac/writer-role.yaml index 5866de2725..6f5b5e3e66 100644 --- a/helm/kagent/templates/rbac/writer-role.yaml +++ b/helm/kagent/templates/rbac/writer-role.yaml @@ -74,9 +74,9 @@ {{- end -}} {{- include "kagent.rbac.validate" . -}} -{{- if .Values.rbac.namespaces }} -{{- $namespaces := .Values.rbac.namespaces | uniq | sortAlpha }} -{{- range $namespace := $namespaces }} +{{- $rbacNamespaces := include "kagent.rbacNamespaces" . | fromJsonArray }} +{{- if $rbacNamespaces }} +{{- range $namespace := $rbacNamespaces }} --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role diff --git a/helm/kagent/templates/rbac/writer-rolebinding.yaml b/helm/kagent/templates/rbac/writer-rolebinding.yaml index d2120b35ee..3045e80869 100644 --- a/helm/kagent/templates/rbac/writer-rolebinding.yaml +++ b/helm/kagent/templates/rbac/writer-rolebinding.yaml @@ -1,6 +1,7 @@ {{- include "kagent.rbac.validate" . -}} -{{- if .Values.rbac.namespaces }} -{{- range $namespace := (.Values.rbac.namespaces | uniq) }} +{{- $rbacNamespaces := include "kagent.rbacNamespaces" . | fromJsonArray }} +{{- if $rbacNamespaces }} +{{- range $namespace := $rbacNamespaces }} --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding diff --git a/helm/kagent/templates/substrate-workerpool.yaml b/helm/kagent/templates/substrate-workerpool.yaml index ac46a7a252..fc217e46d1 100644 --- a/helm/kagent/templates/substrate-workerpool.yaml +++ b/helm/kagent/templates/substrate-workerpool.yaml @@ -15,7 +15,7 @@ metadata: {{- end }} spec: replicas: {{ .Values.substrateWorkerPool.replicas }} - workerImage: {{ .Values.substrateWorkerPool.workerImage | quote }} + workerImage: {{ include "kagent.mirroredImage" (dict "root" . "image" .Values.substrateWorkerPool.workerImage) | quote }} sandboxClass: {{ .Values.substrateWorkerPool.sandboxClass | default "gvisor" | quote }} {{- with .Values.substrateWorkerPool.template }} template: diff --git a/helm/kagent/templates/ui-deployment.yaml b/helm/kagent/templates/ui-deployment.yaml index ef04ecd348..bc5f609e57 100644 --- a/helm/kagent/templates/ui-deployment.yaml +++ b/helm/kagent/templates/ui-deployment.yaml @@ -28,10 +28,7 @@ spec: {{- $podLabels := mergeOverwrite (dict) (.Values.podLabels | default dict) (.Values.ui.podLabels | default dict) (include "kagent.ui.selectorLabels" . | fromYaml) }} {{- toYaml $podLabels | nindent 8 }} spec: - {{- with .Values.imagePullSecrets }} - imagePullSecrets: - {{- toYaml . | nindent 8 }} - {{- end }} + {{- with include "kagent.imagePullSecrets" (dict "root" .) }}{{- . | nindent 6 }}{{- end }} {{- with (.Values.ui.podSecurityContext | default .Values.podSecurityContext) }} securityContext: {{- toYaml . | nindent 8 }} @@ -66,8 +63,8 @@ spec: securityContext: {{- toYaml . | nindent 12 }} {{- end }} - image: "{{ .Values.ui.image.registry | default .Values.registry }}/{{ .Values.ui.image.repository }}:{{ coalesce .Values.tag .Values.ui.image.tag .Chart.Version }}" - imagePullPolicy: {{ .Values.ui.image.pullPolicy | default .Values.imagePullPolicy }} + image: {{ include "kagent.uiImage" . | quote }} + imagePullPolicy: {{ include "kagent.imagePullPolicy" (dict "root" . "local" .Values.ui.image.pullPolicy) }} {{- /* The UI is a static bundle; these are read by init.sh, which renders them into the config.json the browser fetches at startup. Nothing here is consumed by a server process. */}} diff --git a/helm/kagent/tests/controller-deployment_test.yaml b/helm/kagent/tests/controller-deployment_test.yaml index d727c4fdf2..2991931c50 100644 --- a/helm/kagent/tests/controller-deployment_test.yaml +++ b/helm/kagent/tests/controller-deployment_test.yaml @@ -262,6 +262,30 @@ tests: path: data.WATCH_NAMESPACES value: "explicit-ns" + - it: should derive watch namespaces from global.watchNamespaces, install namespace included + template: controller-configmap.yaml + set: + global: + watchNamespaces: + - team-a + asserts: + - equal: + path: data.WATCH_NAMESPACES + value: "NAMESPACE,team-a" + + - it: should watch all namespaces when an explicit empty rbac.namespaces overrides the global + template: controller-configmap.yaml + set: + rbac: + namespaces: [] + global: + watchNamespaces: + - team-a + asserts: + - equal: + path: data.WATCH_NAMESPACES + value: "" + - it: should set podAnnotations template: controller-deployment.yaml set: diff --git a/helm/kagent/tests/global-values_test.yaml b/helm/kagent/tests/global-values_test.yaml new file mode 100644 index 0000000000..da360e223d --- /dev/null +++ b/helm/kagent/tests/global-values_test.yaml @@ -0,0 +1,195 @@ +suite: test global values +templates: + - controller-deployment.yaml + - postgresql-secret.yaml + - controller-configmap.yaml + - rbac/getter-role.yaml + - substrate-workerpool.yaml + # ui-deployment.yaml includes ui-nginx-configmap.yaml, and helm-unittest can + # only resolve cross-template includes when both are listed here. + - ui-deployment.yaml + - ui-nginx-configmap.yaml +tests: + - it: should override every image registry from global.imageRegistry + set: + global.imageRegistry: mirror.example + template: controller-deployment.yaml + asserts: + - matchRegex: + path: spec.template.spec.containers[0].image + pattern: "^mirror\\.example/kagent-dev/kagent/controller:" + - it: should rewrite the worker pool image onto global.imageRegistry + set: + global.imageRegistry: mirror.example + controller.substrate.enabled: true + substrateWorkerPool.create: true + substrateWorkerPool.workerImage: ghcr.io/kagent-dev/substrate/ateom:v1 + template: substrate-workerpool.yaml + asserts: + - equal: + path: spec.workerImage + value: mirror.example/kagent-dev/substrate/ateom:v1 + - it: should leave the worker pool image alone when the global is unset + set: + controller.substrate.enabled: true + substrateWorkerPool.create: true + substrateWorkerPool.workerImage: ghcr.io/kagent-dev/substrate/ateom:v1 + template: substrate-workerpool.yaml + asserts: + - equal: + path: spec.workerImage + value: ghcr.io/kagent-dev/substrate/ateom:v1 + - it: should tolerate a trailing slash on global.imageRegistry + set: + global.imageRegistry: "mirror.example/" + controller.substrate.enabled: true + substrateWorkerPool.create: true + substrateWorkerPool.workerImage: ghcr.io/kagent-dev/substrate/ateom:v1 + template: substrate-workerpool.yaml + asserts: + - equal: + path: spec.workerImage + value: mirror.example/kagent-dev/substrate/ateom:v1 + # Nothing reads IMAGE_REGISTRY from the pod environment: agent workload images + # come from each harness spec, not from this env. The global must therefore + # not touch it -- rewiring it would claim mirror coverage the runtime does + # not have. This pins the removal itself. + - it: should keep the agent-runtime env on its per-image registry under the global + set: + global.imageRegistry: mirror.example + template: controller-configmap.yaml + asserts: + - equal: + path: data.IMAGE_REGISTRY + value: ghcr.io + - it: should point the ui image at global.imageRegistry + set: + global.imageRegistry: mirror.example + template: ui-deployment.yaml + asserts: + - matchRegex: + path: spec.template.spec.containers[0].image + pattern: "^mirror\\.example/" + - it: should keep per-image registry when the global is unset + template: controller-deployment.yaml + asserts: + - matchRegex: + path: spec.template.spec.containers[0].image + pattern: "^ghcr\\.io/kagent-dev/kagent/controller:" + - it: should merge global.imagePullSecrets with the local list + set: + imagePullSecrets: + - name: local + global.imagePullSecrets: + - name: shared + template: controller-deployment.yaml + asserts: + - contains: + path: spec.template.spec.imagePullSecrets + content: + name: local + - contains: + path: spec.template.spec.imagePullSecrets + content: + name: shared + - it: should render namespaced RBAC from global.watchNamespaces alone + release: + namespace: NAMESPACE + set: + global.watchNamespaces: + - NAMESPACE + - other + template: rbac/getter-role.yaml + asserts: + - containsDocument: + apiVersion: rbac.authorization.k8s.io/v1 + kind: Role + namespace: other + any: true + - it: should let rbac.namespaces win over global.watchNamespaces + release: + namespace: NAMESPACE + set: + rbac.namespaces: + - NAMESPACE + global.watchNamespaces: + - NAMESPACE + - other + template: rbac/getter-role.yaml + asserts: + - containsDocument: + apiVersion: rbac.authorization.k8s.io/v1 + kind: Role + namespace: NAMESPACE + any: true + - not: true + containsDocument: + apiVersion: rbac.authorization.k8s.io/v1 + kind: Role + namespace: other + any: true + + - it: should let an explicit empty rbac.namespaces restore cluster-scoped RBAC under the global + release: + namespace: NAMESPACE + set: + rbac.namespaces: [] + global.watchNamespaces: + - NAMESPACE + - other + template: rbac/getter-role.yaml + asserts: + - containsDocument: + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + any: true + + - it: should auto-append the install namespace when scope comes only from the global + release: + namespace: NAMESPACE + set: + global.watchNamespaces: + - other + template: rbac/getter-role.yaml + asserts: + - containsDocument: + apiVersion: rbac.authorization.k8s.io/v1 + kind: Role + namespace: NAMESPACE + any: true + - containsDocument: + apiVersion: rbac.authorization.k8s.io/v1 + kind: Role + namespace: other + any: true + + - it: should fold controller.watchNamespaces into the RBAC scope under the global + release: + namespace: NAMESPACE + set: + global.watchNamespaces: + - NAMESPACE + controller.watchNamespaces: + - NAMESPACE + - extra + template: rbac/getter-role.yaml + asserts: + - containsDocument: + apiVersion: rbac.authorization.k8s.io/v1 + kind: Role + namespace: extra + any: true + + - it: should fail when the watch exceeds an explicit rbac.namespaces + release: + namespace: NAMESPACE + set: + rbac.namespaces: + - NAMESPACE + controller.watchNamespaces: + - NAMESPACE + - outside + template: rbac/getter-role.yaml + asserts: + - failedTemplate: + errorPattern: 'controller\.watchNamespaces includes "outside" but rbac\.namespaces does not' diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index a19021a215..7fc82c40ec 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -14,11 +14,41 @@ ipv6: enabled: false +# Values under `global` are visible to this chart and to every subchart. A parent +# chart or an operator sets one value here instead of one value per chart. +global: + # -- Registry that overrides every per-image registry when set. This is the + # air-gap mirror knob: one value redirects all images. Repository and tag stay + # per-image. For per-image control, leave this unset and set the per-image + # registry keys. Covers this chart's own images: controller, ui, bundled + # postgres, grafana-mcp, and substrateWorkerPool.workerImage. The vendored + # subcharts (kagent-tools, kmcp, substrate) adopt it when their pinned + # versions bump. + imageRegistry: "" + # -- Pull secrets for every pod. The chart merges this list (union) into each + # pod's own imagePullSecrets, so a local secret is never removed. Coverage + # matches imageRegistry above. + imagePullSecrets: [] + # -- Fallback imagePullPolicy where neither a component nor the top-level + # imagePullPolicy sets one. + imagePullPolicy: "" + # -- Namespace scope for the whole install. A non-empty list replaces + # ClusterRoles with Roles and scopes the controller's watch. Both derive from + # this one value, so they cannot disagree. Prefer this value over the + # per-surface keys. rbac.namespaces overrides it when the key is present. An + # explicit empty rbac.namespaces restores cluster-scoped RBAC. Mixing in + # controller.watchNamespaces is validated: the watch must stay inside the RBAC + # scope. A watched namespace without a Role is a permanent Forbidden loop at + # runtime. + watchNamespaces: [] + tag: "" registry: "ghcr.io" imagePullSecrets: [] -imagePullPolicy: IfNotPresent +# -- Pull policy for all containers. Empty falls back to global.imagePullPolicy, +# then IfNotPresent. +imagePullPolicy: "" nameOverride: "" fullnameOverride: "" @@ -109,8 +139,10 @@ database: name: postgres # -- Bundled PostgreSQL image tag tag: "18.6-alpine3.23" - # -- Bundled PostgreSQL image pull policy - pullPolicy: IfNotPresent + # -- Bundled PostgreSQL image pull policy. Empty falls back to the + # top-level imagePullPolicy, then global.imagePullPolicy, then + # IfNotPresent. A declared default here would shadow both fallbacks. + pullPolicy: "" # -- PersistentVolumeClaim size for demo PostgreSQL data storage: 500Mi # -- StorageClass for the PostgreSQL PVC. Defaults to the cluster default when empty. @@ -160,12 +192,18 @@ database: rbac: # -- Namespaces in which to create Role and RoleBinding resources. - # If empty (default), the chart creates cluster-scoped ClusterRole and ClusterRoleBinding - # resources and the controller watches all namespaces. - # If set, the chart creates a Role + RoleBinding per listed namespace and the controller's - # WATCH_NAMESPACES is derived from this list (unless controller.watchNamespaces is set - # explicitly, which always takes precedence). - namespaces: [] + # Left unset (the default), the scope falls back to global.watchNamespaces. + # If that is also unset, the chart creates cluster-scoped ClusterRole and + # ClusterRoleBinding resources, and the controller watches all namespaces. + # If set, the chart creates a Role and a RoleBinding per listed namespace. + # The controller's WATCH_NAMESPACES derives from the same list, unless + # controller.watchNamespaces overrides it. + # An explicit empty list forces cluster-scoped RBAC, even under a parent that + # sets global.watchNamespaces. This is why the key ships commented out: a + # declared default [] would make every install look explicitly cluster-scoped, + # and the global fallback would be unreachable. + # namespaces: [] + {} # ============================================================================== # CONTROLLER CONFIGURATION diff --git a/helm/tools/grafana-mcp/templates/_helpers.tpl b/helm/tools/grafana-mcp/templates/_helpers.tpl index 1f2d8dcbb8..a1bf0b5413 100644 --- a/helm/tools/grafana-mcp/templates/_helpers.tpl +++ b/helm/tools/grafana-mcp/templates/_helpers.tpl @@ -102,10 +102,45 @@ check off behind a proxy that rewrites Host. {{- end }} {{/* -Join registry/repository/name/tag for grafana-mcp image, skipping empty segments, then append tag +The container image. image.registry is a host only and image.repository is the +path below it, so global.imageRegistry replaces the host outright -- the same +substitution every other image in this chart family uses. The registry once +held a Docker Hub organization; that shape is refused rather than rendered, +because prepending a mirror to it would turn the old host into a directory and +the operator would find out at pull time. The global is trimmed of a trailing +slash because the join below adds its own, and a double slash is an invalid +reference that fails at pull time. */}} {{- define "grafana-mcp.image" -}} {{- $img := .Values.image -}} -{{- $parts := compact (list $img.registry $img.repository $img.name) -}} +{{- if hasKey $img "name" -}} +{{- fail "image.name was removed: append it to image.repository instead (e.g. repository: grafana/mcp-grafana)." -}} +{{- end -}} +{{- if and $img.registry (not (or (contains "." $img.registry) (contains ":" $img.registry) (eq $img.registry "localhost") (ne $img.registry ($img.registry | lower)))) -}} +{{- fail (printf "image.registry (%q) is not a registry host. image.registry now takes only a host (default docker.io) and image.repository takes the full path below it (e.g. grafana/mcp-grafana). Move the organization into image.repository." $img.registry) -}} +{{- end -}} +{{- $mirror := ((.Values.global).imageRegistry) | default "" | trimSuffix "/" -}} +{{- $registry := $mirror | default $img.registry -}} +{{- $parts := compact (list $registry $img.repository) -}} {{- printf "%s:%s" (join "/" $parts) $img.tag -}} -{{- end -}} \ No newline at end of file +{{- end -}} +{{/* +Pull secrets for the pod: the chart's own list merged (union) with +global.imagePullSecrets. Renders nothing when both are empty. +*/}} +{{- define "grafana-mcp.imagePullSecrets" -}} +{{- $merged := concat (.Values.imagePullSecrets | default list) (((.Values.global).imagePullSecrets) | default list) | uniq -}} +{{- if $merged -}} +imagePullSecrets: +{{- toYaml $merged | nindent 2 }} +{{- end -}} +{{- end -}} + +{{/* +imagePullPolicy: image.pullPolicy, then global.imagePullPolicy, then Always. +The terminal default stays Always, this chart's previous declared default, so +an install that sets neither value keeps the pull behavior it already has. +*/}} +{{- define "grafana-mcp.imagePullPolicy" -}} +{{- .Values.image.pullPolicy | default ((.Values.global).imagePullPolicy) | default "Always" -}} +{{- end -}} diff --git a/helm/tools/grafana-mcp/templates/deployment.yaml b/helm/tools/grafana-mcp/templates/deployment.yaml index 299610920a..922e0c1b24 100644 --- a/helm/tools/grafana-mcp/templates/deployment.yaml +++ b/helm/tools/grafana-mcp/templates/deployment.yaml @@ -19,6 +19,7 @@ spec: {{- include "grafana-mcp.selectorLabels" . | nindent 8 }} spec: serviceAccountName: {{ include "grafana-mcp.serviceAccountName" . }} + {{- with include "grafana-mcp.imagePullSecrets" . }}{{- . | nindent 6 }}{{- end }} {{- with .Values.podSecurityContext }} securityContext: {{- toYaml . | nindent 8 }} @@ -36,7 +37,7 @@ spec: securityContext: {{- toYaml .Values.securityContext | nindent 12 }} image: {{ include "grafana-mcp.image" . }} - imagePullPolicy: {{ .Values.image.pullPolicy | default "IfNotPresent" }} + imagePullPolicy: {{ include "grafana-mcp.imagePullPolicy" . }} args: - --transport - streamable-http diff --git a/helm/tools/grafana-mcp/tests/deployment_test.yaml b/helm/tools/grafana-mcp/tests/deployment_test.yaml index 99de70d16f..21e3c8566f 100644 --- a/helm/tools/grafana-mcp/tests/deployment_test.yaml +++ b/helm/tools/grafana-mcp/tests/deployment_test.yaml @@ -106,7 +106,7 @@ tests: asserts: - equal: path: spec.template.spec.containers[0].image - value: grafana/mcp-grafana:1.4.1 + value: docker.io/grafana/mcp-grafana:1.4.1 - it: should omit empty registry segment in image template: deployment.yaml @@ -116,11 +116,36 @@ tests: asserts: - equal: path: spec.template.spec.containers[0].image - value: mcp-grafana:1.4.1 + value: grafana/mcp-grafana:1.4.1 - notMatchRegex: path: spec.template.spec.containers[0].image pattern: "^/" # no leading slash + - it: should substitute the registry host from global.imageRegistry + template: deployment.yaml + set: + global.imageRegistry: mirror.example + asserts: + - equal: + path: spec.template.spec.containers[0].image + value: mirror.example/grafana/mcp-grafana:1.4.1 + + - it: should refuse an organization in image.registry + template: deployment.yaml + set: + image.registry: grafana + asserts: + - failedTemplate: + errorPattern: 'image\.registry \("grafana"\) is not a registry host' + + - it: should refuse a removed image.name + template: deployment.yaml + set: + image.name: grafana + asserts: + - failedTemplate: + errorPattern: 'image\.name was removed' + # ============================================================================= # Host allow-list # @@ -157,3 +182,31 @@ tests: - equal: path: spec.template.spec.containers[0].args[3] value: "*" + + # The default tag is mutable, so the terminal pull policy must stay Always; + # the values default is empty so global.imagePullPolicy can reach this chart. + - it: should default the pull policy to Always + template: deployment.yaml + asserts: + - equal: + path: spec.template.spec.containers[0].imagePullPolicy + value: Always + + - it: should fall back to global.imagePullPolicy when image.pullPolicy is empty + template: deployment.yaml + set: + global.imagePullPolicy: IfNotPresent + asserts: + - equal: + path: spec.template.spec.containers[0].imagePullPolicy + value: IfNotPresent + + - it: should let image.pullPolicy win over the global + template: deployment.yaml + set: + image.pullPolicy: Never + global.imagePullPolicy: IfNotPresent + asserts: + - equal: + path: spec.template.spec.containers[0].imagePullPolicy + value: Never diff --git a/helm/tools/grafana-mcp/values.yaml b/helm/tools/grafana-mcp/values.yaml index 8e81f4b3a8..699cc819ad 100644 --- a/helm/tools/grafana-mcp/values.yaml +++ b/helm/tools/grafana-mcp/values.yaml @@ -7,12 +7,21 @@ grafana: # secretRef: "" # Name of Secret to reference (contains GRAFANA_SERVICE_ACCOUNT_TOKEN or GRAFANA_API_KEY) image: - registry: grafana - repository: mcp-grafana - pullPolicy: Always + # -- Registry host only (a mirror or docker.io), never a path: the global + # override replaces this whole value, so a path segment here would be lost + # under global.imageRegistry. The path lives in repository. + registry: docker.io + repository: grafana/mcp-grafana + # -- Pull policy for this image. Empty falls back to global.imagePullPolicy, + # then to Always, this chart's previous declared default. A value declared + # here would shadow the global, which is why the default is empty. + pullPolicy: "" tag: "1.4.1" # docker.io/mcp/grafana (Docker's MCP Catalog mirror) only ever ships "latest"; # pin to the upstream grafana/mcp-grafana image instead, which tags releases. +# -- Pull secrets for the pod, merged (union) with global.imagePullSecrets. +imagePullSecrets: [] + nameOverride: "" fullnameOverride: ""