feat(helm): global values for registry, pull config, and namespace scope - #2817
Conversation
global.imageRegistry overrides every per-image registry at once -- the air-gap mirror knob, resolved by a shared kagent.images.image helper so repository and tag stay per-image and a mirror serves each image under its existing path. global.imagePullSecrets merges (union) into each pod's own list via kagent.imagePullSecrets. global.imagePullPolicy fills the gap where neither a component nor the top-level imagePullPolicy sets one, via kagent.imagePullPolicy; declared IfNotPresent defaults moved into the template chains so the fallback is reachable. global.watchNamespaces is the namespace-scope fallback: rbac.namespaces overrides it when present -- including an explicit empty list, which is why the declared [] default ships commented out -- and on the global path the install namespace and any controller.watchNamespaces entries are folded into the resolved scope rather than failing the render. For an explicit rbac.namespaces, a watch outside the list fails the render and names both keys: a watched namespace without a Role is a permanent Forbidden loop at runtime. writer-rolebinding gains the sortAlpha the other three rbac templates already had. grafana-mcp adopts the globals in the same change, and gains the pull-secret surface its pod spec lacked entirely. Verified: helm unittest passes (288 tests, including a global-values suite pinning override, merge, fallback, fold-in, auto-append, explicit-empty opt-out, and the watch-outside-scope failure); a default render is byte-identical to main for images, pull secrets, and RBAC kinds. Signed-off-by: Jonathan Jamroga <jjamroga@gmail.com>
| @@ -59,21 +59,66 @@ Precedence: controller.watchNamespaces (explicit override) > rbac.namespaces > e | |||
| {{- .Values.controller.watchNamespaces | uniq | join "," -}} | |||
| {{- else if and .Values.rbac .Values.rbac.namespaces -}} | |||
| {{- .Values.rbac.namespaces | uniq | join "," -}} | |||
| {{- else if ((.Values.global).watchNamespaces) -}} | |||
| {{- (.Values.global).watchNamespaces | uniq | join "," -}} | |||
There was a problem hiding this comment.
If I'm reading this right, the watch helper reads the raw global while RBAC uses the resolved list. With release namespace kagent and only global.watchNamespaces=[team-a], the chart creates Roles in both namespaces but emits WATCH_NAMESPACES=team-a, so the install namespace is missing from both controller caches and Harnesses, AgentTemplates, ModelConfigs and MCP resources there are not watched (go/core/pkg/app/app.go:203-224 configures both caches from this value, and go/core/internal/controller/collections.go:60-70 applies it to the collections). The auto-append test only checks the Role.
There's a second effect of the separate fallback. With rbac.namespaces=[] and that same global, RBAC correctly goes cluster-wide but the watch stays restricted to team-a, which changes an existing explicitly cluster-wide configuration. Would it make sense to keep the controller.watchNamespaces override, then derive the default watch from kagent.rbacNamespaces with its key-presence and install-namespace rules, and test WATCH_NAMESPACES in both cases?
There was a problem hiding this comment.
You read it correctly, and thank you for the trace into the cache setup. Fixed in 1f8d37e. The watch helper no longer reads the raw global. It now derives from the same resolved list that the RBAC templates use. The two cannot disagree now. A scope from global.watchNamespaces includes the install namespace. An explicit rbac.namespaces: [] restores the cluster-wide watch. controller.watchNamespaces stays an explicit override. Two new tests pin the global-only case and the explicit-empty case.
| @@ -12,9 +12,10 @@ data: | |||
| {{- if .Values.ui.externalUrl }} | |||
| KAGENT_UI_URL: {{ .Values.ui.externalUrl | quote }} | |||
| {{- end }} | |||
| IMAGE_REGISTRY: {{ .Values.controller.agentImage.registry | default .Values.registry | quote }} | |||
| IMAGE_REGISTRY: {{ ((.Values.global).imageRegistry) | default (.Values.controller.agentImage.registry | default .Values.registry) | quote }} | |||
There was a problem hiding this comment.
I'm not sure that changing IMAGE_REGISTRY redirects agent runtime images in the current controller. I didn't see a Go reader of IMAGE_REGISTRY, IMAGE_REPOSITORY or IMAGE_TAG at this head, and the harness compilers copy harness.Spec.Workload.Image into the revision (go/core/internal/translator/kagent/compiler.go:90). The optional substrate-workerpool.yaml also emits substrateWorkerPool.workerImage unchanged (line 18).
When I test rendered a WorkerPool under global.imageRegistry=mirror.example, its ghcr.io/.../ateom:v1 stayed on ghcr.io. The new ConfigMap test passes without showing that an agent can run from the mirror. Could the global be wired to the actual runtime image configuration, with a test that inspects the resulting runtime or WorkerPool image?
There was a problem hiding this comment.
Confirmed. No code reads IMAGE_REGISTRY from the environment, and the harness compilers copy the workload image unchanged. The override changed a dead value. Fixed in 901e4c0: the configmap line is back to its previous expression, and the values comment no longer claims that coverage. The real gap was substrateWorkerPool.workerImage, which the chart emitted unchanged. It now goes through a rewrite helper that applies the container runtime's registry rule: a host segment is replaced, a bare name gets a prefix, and an unset global changes nothing. 6c1f5fa adds a negative test: the environment value must stay on its per-image registry when the global is set.
| */}} | ||
| {{- define "grafana-mcp.imagePullPolicy" -}} | ||
| {{- .Values.image.pullPolicy | default ((.Values.global).imagePullPolicy) | default "IfNotPresent" -}} |
There was a problem hiding this comment.
I think this fallback is unreachable for a normal install, since image.pullPolicy still defaults to Always in this subchart's values (values.yaml:9-13). With only global.imagePullPolicy=Never, the controller, UI, tools and kmcp all use Never, while grafana-mcp, which is enabled by default, still uses Always and contacts the registry. Would it work to move the default out of values and into the helper's final fallback, keeping Always as this chart's default when neither a local nor a global policy is set? An explicit local policy would still win, and the existing latest image behavior wouldn't change.
There was a problem hiding this comment.
Yes, that works, and it is what we did in 1852efd. The values default is now empty. The helper's final fallback is Always. The precedence is: local value, then global value, then Always. After the merge with main, the image tag is pinned, so the old mutable-tag reason for Always is gone. We keep Always as the terminal fallback so that the default render does not change in this PR. A move to IfNotPresent can be a separate change. Three tests pin the default, the global fallback, and a local override.
| {{/* image.registry holds the docker.io org here ("mcp"), not a host, so the | ||
| air-gap override is prepended rather than substituted: the mirror serves | ||
| the image under its existing mcp/grafana path. */}} | ||
| {{- $parts := compact (list ((.Values.global).imageRegistry) $img.registry $img.repository $img.name) -}} |
There was a problem hiding this comment.
The prepend works for the default Docker Hub shorthand (mcp/grafana), but image.registry is operator-configurable and can already hold a host. image.registry=docker.io, image.repository=mcp, image.name=grafana renders a valid docker.io/mcp/grafana:latest today, and with global.imageRegistry=mirror.example this helper emits mirror.example/docker.io/mcp/grafana:latest, which adds a directory rather than replacing the registry and disagrees with every other image override in the stack. Could the default shorthand be normalized to a registry plus repository, or the helper tell a real host apart from the shorthand before applying the global?
There was a problem hiding this comment.
Agreed. The image.registry key held a Docker Hub organization, not a host, so the global could only be prepended. An operator who put a real host in the key got mirror.example/docker.io/... -- a directory, not a registry. Fixed in 6c35a1e. image.registry now takes only a host, with the default docker.io. The organization moves into image.repository. The global replaces the host, the same as every other image in the stack. A values file with the old shape fails the render, and the message names the new shape. Tests pin the default, the substitution, and the two refusals.
There was a problem hiding this comment.
Should we remove these if they're in global, could be very confusing, Do we need a 3 level override?
There was a problem hiding this comment.
We keep the three levels, per our discussion: component value, then top-level value, then global value. The top level stays because removing it breaks every existing values file that sets it, and because one chart in an umbrella install sometimes must differ from the global. The values comments state the precedence at each key.
WATCH_NAMESPACES read only rbac.namespaces and controller.watchNamespaces, so a scope set through global.watchNamespaces produced namespaced Roles while the controller still watched every namespace -- a cluster-wide cache backed by Roles that do not cover it. The watch now derives from the same resolved scope the RBAC templates use, so the two cannot disagree: global-only scope watches the listed namespaces plus the install namespace, and an explicit rbac.namespaces: [] restores cluster-wide watch as well as cluster-scoped RBAC. controller.watchNamespaces stays an explicit override, unchanged. Verified with helm unittest (290 passing, two new pins for the global-only and explicit-empty cases) and by rendering all four scope combinations. Signed-off-by: Jonathan Jamroga <jjamroga@gmail.com>
…thing reads The global registry override was wired into the configmap's IMAGE_REGISTRY, but no code reads that key from the environment -- agent workload images are copied verbatim from each harness spec, so the override changed a dead value and the values comment claimed coverage the chart did not have. The configmap line goes back to its previous expression. The real single-string image boundary is substrateWorkerPool.workerImage, which was emitted verbatim and escaped the mirror. It now routes through kagent.mirroredImage, which applies the container runtime's first-segment rule: a host-carrying reference has its registry replaced, a bare name is prefixed, and an unset global passes the reference through untouched. Verified with helm unittest (291 passing; the agent-runtime pin is repointed at the WorkerPool plus a pass-through pin) and by rendering a bare-name workerImage under the global. Signed-off-by: Jonathan Jamroga <jjamroga@gmail.com>
The chart declared pullPolicy: Always in its values, so the key was always present and the global fallback could never fire -- the one chart in the family whose global pull-policy knob did nothing. The declared value moves into the helper as the terminal default and the values default becomes empty, the same shape every other chart here uses. The terminal default stays Always rather than IfNotPresent because the default tag is mutable (latest is the only published tag): with a cached image, IfNotPresent never picks up a new push of the same tag. Verified with helm unittest: three new pins cover the Always default, the global fallback, and a local override winning over the global. Signed-off-by: Jonathan Jamroga <jjamroga@gmail.com>
… what the mirror must not touch A trailing slash is an easy value to ship (mirror.example/), and every consumer joins the registry onto a path with its own slash, so the raw value rendered image references with a double slash that fail at pull time. The global now resolves through one helper that trims it, used by the controller, ui and postgres images and by the worker-pool rewrite; grafana-mcp trims inline because it is its own chart. The single-string rewrite also gains the container runtime's fourth registry test: a first segment with an uppercase letter is a host, because repository paths are lowercase-only. Registry/foo:v1 now substitutes the host instead of rendering an invalid prefixed reference. Two absences are now pinned as well: the configmap's IMAGE_REGISTRY env must stay on its per-image registry under a set global (nothing reads it from the environment, so mirroring it would claim coverage the runtime does not have), and the ui image must follow the global. The suite's controller-configmap entry runs an assertion again instead of sitting dead in the templates list. Verified with helm unittest (310 passing) and by rendering with a trailing-slash global across every image and with an uppercase-host worker image; the default render is byte-identical to the branch tip. Signed-off-by: Jonathan Jamroga <jjamroga@gmail.com>
# Conflicts: # helm/tools/grafana-mcp/values.yaml
… remains Upstream pinned grafana-mcp to a release tag, so the pull-policy helper's stated reason for Always (a mutable latest tag) is no longer true. The terminal default stays Always so the default render does not change; only the comment now says why. Signed-off-by: Jonathan Jamroga <jjamroga@gmail.com>
…hape image.registry held a Docker Hub organization, not a host, so the global override could only be prepended -- correct for the default shorthand, but an operator who put a host in the key got mirror.example/docker.io/...: the old host turned into a directory, and this one chart disagreed with every other image override in the stack. image.registry now takes only a host, default docker.io, and the organization moves into image.repository. The global replaces the host outright, the same substitution the rest of the family uses. A values file still carrying the old shape fails the render with a message naming the new one, rather than pulling from a path that does not exist. The unused image.name segment is refused the same way. The default reference gains an explicit docker.io prefix; it resolves to the same image. Verified with helm unittest (19 in the chart, 329 in the umbrella) and by rendering the umbrella under a trailing-slash mirror and under an old-shape override, which fails with the guard's message. Signed-off-by: Jonathan Jamroga <jjamroga@gmail.com>
|
We did an internal review of this branch. We found more issues. Some are fixed in new commits. Some are notes for the release. Fixed in code:
Upgrade notes:
|
What this gives operators
This PR adds a
global:block. Each install-wide setting becomes one value. Helm passesglobal.*into subcharts automatically, so these values also reach the vendored subcharts as those adopt them.global.imageRegistry— for air-gapped and private-registry installs. Mirror the images, then set this one value. Every image this chart composes pulls from the mirror: controller, ui, the bundled postgres, grafana-mcp, and the agent runtime the controller launches (IMAGE_REGISTRY). Repository paths and tags stay per-image, so a mirror copies each image under its existing path.global.imagePullSecrets— the pull secret that mirror needs. The chart merges it (union) into each pod's own list. A local secret is never removed.global.imagePullPolicy— one pull policy for the install. An explicitly set component policy or top-levelimagePullPolicystill wins.global.watchNamespaces— a namespace-scoped install in one value. A non-empty list renders Roles instead of ClusterRoles for the getter and writer RBAC. The controller'sWATCH_NAMESPACESderives from the same list. RBAC scope and watch scope cannot disagree.Image references resolve through one helper,
kagent.images.image. It implements the precedence in one place: a set global overrides the per-image registry, repository and tag stay per-image, and a digest pins the image in place of the tag. The name is chart-scoped because Helmdefinenames are global across a release. A generic name could collide with another chart's helper, and the last-loaded copy would win silently.Not a breaking change
Every new key is opt-in. A default render is byte-identical to main. Two behavior notes:
rbac.namespaces: [a]withcontroller.watchNamespaces: [a, b]rendered successfully. The controller then watched namespacebwith no Role in it. Every reconcile there returnedForbiddenat runtime, with only a log line to show for it. That mix now fails the render, and the error names both keys and the fix. The only configurations that newly fail are ones that never worked.writer-rolebinding.yamlwas the one rbac template withoutsortAlpha; the other three sorted. Object content is unchanged. Tooling that diffs rendered output may see a reorder once.Other compatibility notes
rbac.namespaces: []opts out of the global. An empty list has always meant "create ClusterRoles". The global is only a fallback, so it must not override that choice. But Helm'scoalescecannot see the choice: it skips empty values, so[]and "not set" look the same. The chart therefore checks key presence instead. Example: a parent setsglobal.watchNamespaces: [team-a, team-b]to scope its other subcharts. A kagent values file containsrbac.namespaces: []. That file keeps its ClusterRoles. Without the presence check, the same upgrade would silently replace them with Roles inteam-aandteam-b, and the controller would lose access everywhere else. One consequence:values.yamlno longer declaresnamespaces: []as a default. A declared empty default would make every install look explicitly cluster-scoped, and the global fallback would never fire. The key now ships commented out, with this explanation next to it.global.watchNamespacesfor its other subcharts and forget kagent's namespace. Failing the render for that would brick the whole umbrella install over a list that was never about kagent. So on the global path, the chart auto-appends its install namespace to the resolved scope. It also foldscontroller.watchNamespacesinto that scope, so a wider watch gains matching Roles instead of failing. The hard errors remain for an explicitrbac.namespaces: a list without the install namespace, or a watch outside the list, stops the render and names the fix.IfNotPresentin values. The top-levelimagePullPolicyand the bundled postgres policy move the default into the template's fallback chain. The rendered output is the same. The reason: a declared default is always "set", soglobal.imagePullPolicycould never fire. With the default in the template, the global fallback is reachable.imagePullSecretssupport at all; it gains the merge. Itsimage.registryvalue holds a docker.io org (mcp), not a host, so the mirror override prepends: the mirror servesmcp/grafanaunder its existing path.Verification
A default render is byte-identical to main for images, pull secrets, and RBAC kinds. The chart's test suite passes (288 tests), including a new
global-valuessuite that pins: the registry override, per-image registry retention when the global is unset, the pull-secret merge, namespaced RBAC from the global alone, the install-namespace auto-append, the watch fold-in, the explicit-empty opt-out, local-wins precedence, and the watch-outside-scope failure.🤖 Generated with Claude Code