skip to content

Capabilities & Lookup

Emitting the right apiVersion per cluster with .Capabilities.APIVersions.Has and KubeVersion, and lookup returning nothing on a plain render — why a chart that reads a live Secret fails in CI.

part ofHelmoverview, primer and where to startread it →
on this pageshow

questions

4

Why does Helm's `lookup` function return nothing when a chart is rendered with `helm template`?

level: juniorimportance: must knowfreq 58%

answer

  1. No connection is opened during the render
  2. The one function that needs a cluster
  3. Empty map, not an error
  4. Indistinguishable from an absent object
  5. A server-side render resolves it

basics

~20 s

helm template never contacts an API server, so Helm's lookup has nothing to query and returns an empty map rather than failing. Only a render that reaches a cluster — a real install, an upgrade, or --dry-run=server — fills it in.

solid answer

~50 s

`lookup` takes an apiVersion, a kind, a namespace and a name and asks the cluster for that object: with all four arguments it returns a dict for the object, and with an empty name a dict whose `items` key lists the matches. It is the one Helm template function that needs a live API connection, and `helm template` deliberately opens none — neither does a client-side dry run. In that mode `lookup` returns an empty map and no error, so `{{ if $found }}` quietly takes the false branch. The same empty result appears when the object simply does not exist, so a template cannot tell "no cluster" from "not found". Renders that do reach the cluster — `helm install`, `helm upgrade`, `helm rollback` and `--dry-run=server` — resolve it. Treat `lookup` as an optimisation, never as the guard that protects live data.

code

yaml · 12 lines
yaml
{{- $found := lookup "v1" "Secret" .Release.Namespace "gateway-credentials" }}
apiVersion: v1
kind: Secret
metadata:
  name: gateway-credentials
type: Opaque
data:
{{- if $found }}
  password: {{ index $found.data "password" }}
{{- else }}
  password: {{ randAlphaNum 24 | b64enc }}
{{- end }}

go deeper

for a junior

Be ready to say plainly that helm template renders locally and opens no cluster connection, so lookup comes back empty. That one sentence explains most of the surprising output people see in CI renders.

for a middle

Explain the mechanics: lookup returns an empty map with no error, that result is identical to a missing object, and only a real install, an upgrade or a server-side dry run resolves it.

for a senior

Show the production judgment. A chart whose output depends on lookup renders differently depending on who runs it, so it must never be the guard protecting live data such as an already-generated credential.

for a principal

Own the rule for the organisation: rendered output should be a function of chart plus values. Charts that read live cluster state need an explicit policy for where that is allowed and how consumers pass values in instead.

Helm renders charts with Go templates plus a large function library, and almost every one of those functions is pure: it transforms values that are already in front of it. `lookup` is the exception. It asks a live Kubernetes API server for an object while the template executes, which makes the rendered output depend on something outside the chart and its values. ### Signature and return shape `lookup apiVersion kind namespace name` returns a dict. With all four arguments filled in, that dict is the object itself, so `$found.metadata.labels` or `$found.data` behave as you would expect. With an empty name, the dict carries an `items` key holding the list of matching objects. With an empty namespace, the query is cluster-scoped, which is the right form for kinds that have no namespace. The call is synchronous: it happens at the moment the action is evaluated, once per render. ### What happens with no cluster `helm template` is a local command. It renders and prints, it opens no connection, and it does not need a working kubeconfig — that is precisely why pipelines like it. A client-side dry run behaves the same way for this purpose. In that mode Helm hands `lookup` an empty map. It does not raise an error, there is no error value to catch, and nothing in the output says a query was skipped. Every conditional written around the result therefore takes its false branch, and every field access on the result yields nothing. ### The ambiguity that bites The empty result is also exactly what you get when the render *does* reach a cluster and the object genuinely is not there. A template cannot distinguish the two, because both are an empty map. That is why `lookup` must never be the guard on a decision that destroys something: "if I cannot see it, it does not exist" is a false statement in exactly the situation where it matters most. ### The pattern that keeps breaking The most common use is generating a credential only when none is already present: ```yaml {{- $found := lookup "v1" "Secret" .Release.Namespace "gateway-credentials" }} data: {{- if $found }} password: {{ index $found.data "password" }} {{- else }} password: {{ randAlphaNum 24 | b64enc }} {{- end }} ``` Run by an engineer whose kubeconfig points at the cluster, this preserves the existing password. Run by a CI job that renders offline, it mints a new one on every render — harmless as noise in a diff, and destructive the moment such a render is actually applied, because the live Secret is rewritten while running pods still hold the old value. Worse, the behaviour depends on *who ran it*, which is the hardest class of bug to reproduce. ### Where `lookup` really does resolve Any operation that talks to the cluster: `helm install`, `helm upgrade`, `helm rollback`, and a server-side dry run, where the render is pushed through the API server rather than staying local. A server-side dry run is therefore the honest way to preview a chart whose output depends on cluster state — at the cost of needing cluster access, which a plain offline render does not. ### Consequences for pipelines Because `lookup` is re-evaluated on every install and upgrade, the rendered manifest is not a pure function of chart plus values. Two consequences follow. First, an offline render in CI is no longer proof of what will be applied, so review artefacts built that way can be quietly wrong. Second, any system that renders the chart itself — a GitOps controller such as Argo CD or Flux, for example — produces its own answer to the same `lookup`, which may differ from yours. ### What to do instead Make the value explicit. Let the caller pass an existing-secret name through values, or let an external secret operator own the credential and have the chart reference it. Keep `lookup` for genuinely optional adaptations where an empty answer is a safe answer — adding a label if some other object happens to exist, for instance — and write the template so that the empty case is the conservative one.

  • How can a template tell an absent object apart from a render that had no cluster?
    It cannot. `lookup` returns the same empty result for both and raises no error, so there is nothing to branch on. If the distinction matters, take it out of the template: have the caller pass an explicit value, such as the name of an existing Secret, and treat `lookup` as a convenience that may legitimately return nothing on any given render.
  • What does a server-side dry run change for a chart that calls `lookup`?
    The render goes through the API server instead of staying local, so `lookup` queries resolve against real objects and the preview matches what an install would produce. A client-side dry run cannot do that. The trade-off is that the preview now needs cluster access and credentials, which a plain offline render does not.
  • A chart keeps a generated password stable by looking up its own Secret. What is the failure mode?
    Any render without cluster access takes the else branch and mints a new password. In CI that is diff noise; if such a render is ever applied, the live Secret is rewritten while running pods still hold the old value, and authentication breaks for whatever consumed it. Own the value explicitly instead — pass it in, or let an external secret operator manage it.

It is like asking for yesterday's meter reading with the phone line unplugged: you do not get an error, you get a blank form — and a blank form looks exactly like a meter that was never installed.

saying these in an interview costs you the question

  • Says `lookup` errors out during `helm template`
  • Treats an empty `lookup` result as proof the object is absent
  • Thinks a client-side dry run still queries the cluster
  • Relies on `lookup` to keep a generated password stable
  • Believes `lookup` reads the stored release manifest rather than live objects
  • Assumes Helm caches the previous revision's lookup results

context

open as a page

In a Helm chart, how do `.Capabilities.APIVersions.Has` and `.Capabilities.KubeVersion` decide which apiVersion a template emits?

level: middleimportance: must knowfreq 62%

basics

~10 s

Helm reads the target cluster's discovery list and reported server version before rendering. .Capabilities.APIVersions.Has answers whether that cluster serves a given group/version, and .Capabilities.KubeVersion.Version gives its version string for a semver comparison.

open as a page

A CI job runs `helm template` and emits a different apiVersion than the cluster install does — why, and how do you fix the render?

level: seniorimportance: should knowfreq 44%

basics

~20 s

With no cluster to query, helm template uses capabilities compiled into the Helm binary — a fixed Kubernetes version and a minimal API list — so capability branches take their fallback path. Pass --kube-version and --api-versions to describe the target cluster.

open as a page

How do you decide between Chart.yaml `kubeVersion` and `.Capabilities` branching for a chart installed across many cluster versions?

level: principalimportance: should knowfreq 36%

basics

~20 s

kubeVersion in Chart.yaml is a hard semver gate: Helm refuses to install outside the range. .Capabilities branching keeps one chart usable across a span at the cost of permutations nobody tests. Gate the floor; branch only where behaviour genuinely differs.

open as a page