feat(helm): global values, and the registry/repository image split - #143
Merged
Merged
Conversation
jjamroga
force-pushed
the
jjamroga/helm-global-values
branch
from
September 14, 2026 13:29
886d855 to
912922a
Compare
jjamroga
marked this pull request as ready for review
September 14, 2026 13:39
dhaifley
approved these changes
Sep 14, 2026
| {{- if or (contains "." $first) (contains ":" $first) -}} | ||
| {{- fail (printf "image.repository (%q) carries a registry host. It is now the image path only: move the host into image.registry (or global.imageRegistry) and keep repository as the path, e.g. registry: ghcr.io, repository: kagent-dev/kmcp/controller." .Values.image.repository) -}} | ||
| {{- end -}} | ||
| {{- include "kmcp.images.image" (dict "imageRoot" (dict "registry" .Values.image.registry "repository" .Values.image.repository "tag" $tag) "global" .Values.global) -}} |
There was a problem hiding this comment.
The repository split changes the existing Helm fixtures from test-repo:v1.0.0 to ghcr.io/test-repo:v1.0.0, but this PR only migrates the Go e2e caller. helm unittest helm/kmcp passes all 48 tests on the merge base and fails 18 tests at this head, with 18 snapshot failures in the deployment and RBAC suites. Should we update those fixtures and their expected output, and add cases for the new registry/global behavior? Or, if a fixture is intended to keep an unqualified image, set image.registry: "" explicitly?
Collaborator
Author
There was a problem hiding this comment.
Yep, you're right. Updated the fixtures AND added a CI job to run helm unit tests, this would have been more visible had we been running these tests in CI.
jjamroga
force-pushed
the
jjamroga/helm-global-values
branch
from
September 14, 2026 19:48
912922a to
8197f7d
Compare
Matches the convention landing across the kagent-family charts. image.repository was one string carrying its registry (ghcr.io/kagent-dev/kmcp/controller); it is now the environment-invariant path joined onto a new image.registry key, so every chart in the family resolves images the same way and one global.imageRegistry value (which overrides image.registry) redirects them all for air-gapped mirrors. This is a breaking change for values files that relocated the image by overriding repository with a host-carrying string. Rendered silently, the new default registry would prepend onto them and the doubled path would fail only at pod start, as ImagePullBackOff -- so the render fails instead, naming the split and the migration (move the host into image.registry). A default render is byte-identical to main: only old-shape overrides are affected, and they fail loudly at template time. global.imagePullSecrets merges (union) into the pod's own list via kmcp.imagePullSecrets. global.imagePullPolicy fills the gap when image.pullPolicy is unset via kmcp.imagePullPolicy; the declared IfNotPresent default moved into the template chain 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 is auto-appended rather than failing the render of an install the value was never about. The RBAC loop and the --watch-namespaces flag derive from one resolved list, so RBAC scope and watch scope cannot disagree. Verified: a default render is byte-identical to main; the mirror override, local registry override, old-shape failure message, scope fallback, auto-append, explicit-empty opt-out, local-wins precedence, pull-secret merge and pullPolicy fallback each verified by render. Signed-off-by: Jonathan Jamroga <jjamroga@gmail.com>
jjamroga
force-pushed
the
jjamroga/helm-global-values
branch
from
September 14, 2026 19:50
8197f7d to
47d3cc3
Compare
EItanya
approved these changes
Sep 17, 2026
EItanya
pushed a commit
to marosset/kagent
that referenced
this pull request
Sep 18, 2026
…ope (kagent-dev#2817) ### What this gives operators This PR adds a `global:` block. Each install-wide setting becomes one value. Helm passes `global.*` 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-level `imagePullPolicy` still 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's `WATCH_NAMESPACES` derives 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 Helm `define` names 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: - **One configuration that used to render now fails, on purpose.** Before this PR, `rbac.namespaces: [a]` with `controller.watchNamespaces: [a, b]` rendered successfully. The controller then watched namespace `b` with no Role in it. Every reconcile there returned `Forbidden` at 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. - **Multi-namespace RoleBindings now render in sorted order.** `writer-rolebinding.yaml` was the one rbac template without `sortAlpha`; the other three sorted. Object content is unchanged. Tooling that diffs rendered output may see a reorder once. ### Other compatibility notes - **An explicit `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's `coalesce` cannot see the choice: it skips empty values, so `[]` and "not set" look the same. The chart therefore checks key *presence* instead. Example: a parent sets `global.watchNamespaces: [team-a, team-b]` to scope its other subcharts. A kagent values file contains `rbac.namespaces: []`. That file keeps its ClusterRoles. Without the presence check, the same upgrade would silently replace them with Roles in `team-a` and `team-b`, and the controller would lose access everywhere else. One consequence: `values.yaml` no longer declares `namespaces: []` 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. - **A global that omits kagent's namespace does not break the install.** The chart needs a Role where the controller runs. A parent may set `global.watchNamespaces` for 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 folds `controller.watchNamespaces` into that scope, so a wider watch gains matching Roles instead of failing. The hard errors remain for an explicit `rbac.namespaces`: a list without the install namespace, or a watch outside the list, stops the render and names the fix. - **Pull policies no longer declare `IfNotPresent` in values.** The top-level `imagePullPolicy` and 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", so `global.imagePullPolicy` could never fire. With the default in the template, the global fallback is reachable. - **grafana-mcp (in this repo) adopts the globals too.** Its pod previously had no `imagePullSecrets` support at all; it gains the merge. Its `image.registry` value holds a docker.io org (`mcp`), not a host, so the mirror override prepends: the mirror serves `mcp/grafana` under its existing path. - **Coverage boundary.** The vendored subcharts (kagent-tools, kmcp, substrate) adopt the globals in their own repositories, and this chart picks them up as their pinned versions bump. The values comments state this boundary. Companion PRs: kagent-dev/tools#81, kagent-dev/kmcp#143, kagent-dev/substrate#38. ### 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-values` suite 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](https://claude.com/claude-code) --------- Signed-off-by: Jonathan Jamroga <jjamroga@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this gives operators
This PR adds a
global:block. Each install-wide setting becomes one value. Helm passesglobal.*into subcharts automatically. A parent chart that vendors kmcp (kagent does) sets each value once for its whole install.global.imageRegistry— for air-gapped and private-registry installs. Mirror the image, then set this one value. The chart pulls<your-registry>/kagent-dev/kmcp/controller. The image path is identical on every registry. A mirror copies the image where it already lives.global.imagePullSecrets— the pull secret that mirror needs. The chart merges it (union) into the pod's own list. A local secret is never removed.global.imagePullPolicy— one pull policy for the install. An explicitly setimage.pullPolicystill wins.global.watchNamespaces— a namespace-scoped install in one value. A non-empty list renders Roles instead of ClusterRoles. The controller's--watch-namespacesderives from the same list. RBAC scope and watch scope cannot disagree.The breaking change
image.repositorywas one string that carried its registry:ghcr.io/kagent-dev/kmcp/controller. It is now split.image.registryholds the registry host.image.repositoryholds the image path. The other kagent-family charts use this same shape. The shared shape is what makes one mirror value work across all of them.Who this breaks: values files that relocated the image by overriding
repositorywith a host-carrying string.How the break stays safe: it cannot fail silently. A naive render would prepend the default registry onto the old-shape override. The doubled path would fail only at pod start, as
ImagePullBackOff. Instead, the render fails at template time. The error names the fix:The operator sees one loud
helm upgradeerror. The message contains the migration. No pod fails to pull. Operators who do not overriderepositoryare untouched. A default render is byte-identical to main.We considered a host-detection heuristic instead: use
repositoryas-is when its first segment looks like a registry. That keeps old files rendering. It also embeds the heuristic in the default render path forever. It silently ignoresimage.registrywhenever the old shape appears. That trades one loud migration for a permanent class of quiet misconfiguration. Loud and once won.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 kmcp 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 kmcp's namespace. Failing the render for that would brick the whole umbrella install over a list that was never about kmcp. The operator would then have to add kmcp's namespace to a global list every other chart also reads. So on the global path, the chart auto-appends its install namespace to the resolved scope. The hard error remains for an explicitrbac.namespaces: there the operator is talking about this chart, and a list without the install namespace is a real mistake worth stopping.image.pullPolicyno longer declaresIfNotPresentin values. The template's fallback chain ends atIfNotPresentinstead. The rendered output is the same. The reason for the move: a declared default is always "set", soglobal.imagePullPolicycould never fire. With the default in the template, the global fallback is reachable.Verification
A default render is byte-identical to main. Each behavior is verified by render: the mirror override, a local
image.registryoverride, the old-shape failure message, the scope fallback, the install-namespace auto-append, the explicit-empty opt-out, local-wins precedence, the pull-secret merge, and the pull-policy chain.🤖 Generated with Claude Code