Skip to content

feat(helm): global values, and the registry/repository image split - #143

Merged
EItanya merged 1 commit into
mainfrom
jjamroga/helm-global-values
Sep 17, 2026
Merged

EItanya merged 1 commit into
mainfrom
jjamroga/helm-global-values

Conversation

@jjamroga

Copy link
Copy Markdown
Collaborator

What this gives operators

This PR adds a global: block. Each install-wide setting becomes one value. Helm passes global.* 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 set image.pullPolicy still wins.
  • global.watchNamespaces — a namespace-scoped install in one value. A non-empty list renders Roles instead of ClusterRoles. The controller's --watch-namespaces derives from the same list. RBAC scope and watch scope cannot disagree.

The breaking change

image.repository was one string that carried its registry: ghcr.io/kagent-dev/kmcp/controller. It is now split. image.registry holds the registry host. image.repository holds 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 repository with 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:

image.repository ("my.registry.example/platform/kmcp-controller") 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.

The operator sees one loud helm upgrade error. The message contains the migration. No pod fails to pull. Operators who do not override repository are untouched. A default render is byte-identical to main.

We considered a host-detection heuristic instead: use repository as-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 ignores image.registry whenever the old shape appears. That trades one loud migration for a permanent class of quiet misconfiguration. Loud and once won.

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 kmcp 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 kmcp's namespace does not break the install. The chart needs a Role in its own namespace: the controller's leader election and its manager Role live there. A parent may set global.watchNamespaces for 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 explicit rbac.namespaces: there the operator is talking about this chart, and a list without the install namespace is a real mistake worth stopping.
  • image.pullPolicy no longer declares IfNotPresent in values. The template's fallback chain ends at IfNotPresent instead. The rendered output is the same. The reason for the move: a declared default is always "set", so global.imagePullPolicy could 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.registry override, 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

{{- 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) -}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
jjamroga force-pushed the jjamroga/helm-global-values branch from 912922a to 8197f7d Compare September 14, 2026 19:48
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
jjamroga force-pushed the jjamroga/helm-global-values branch from 8197f7d to 47d3cc3 Compare September 14, 2026 19:50
@EItanya
EItanya merged commit 8f26f04 into main Sep 17, 2026
7 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants