An experiment in converting Go text/template to CUE, using Helm charts as
the driving example.
Go's text/template package is widely used to generate structured output
(YAML, JSON, etc.) from templates with control flow, pipelines, and helper
definitions. CUE can express the same data more directly, with types, defaults,
and constraints instead of string interpolation and whitespace wrangling. This
project explores how far an automated conversion from one to the other can go.
The underlying problem is not specific to Go or Helm. Conversations with people
struggling with Jinja templates in the Python world (Ansible, SaltStack, etc.)
helped motivate this work: any template language that generates structured data
by splicing strings into YAML or JSON hits the same class of issues. Go's
text/template is the starting point because it has a well-defined AST that
can be walked programmatically, but the patterns explored here — mapping
conditionals to guards, loops to comprehensions, defaults to CUE's default
mechanism — should transfer to other template languages.
Helm is a good test case because its templates exercise most of text/template
— conditionals, range loops, nested defines, pipelines with Sprig functions —
and produce structured YAML. The goal is not to replace Helm; Helm's value lies
in packaging, distribution, and lifecycle management, which are orthogonal to
how templates are written. helm2cue is only concerned with the template
conversion question.
helm2cue chart <chart-dir> <output-dir>
Convert an entire Helm chart directory to a CUE module.
helm2cue template [file ...]
Convert individual Go text/template files to CUE. Only Go's built-in
template functions are supported; Helm/Sprig functions are rejected.
Files ending in .tpl are treated as helper files containing
{{ define }} blocks. All other files are treated as the main template.
Reads from stdin if no non-.tpl arguments are given. Generated CUE is
printed to stdout.
helm2cue version
Print version information.
The examples/ directory contains two examples. Both have
their generated output committed so you can browse the result without
running the tool. They are kept in sync via go generate (see
gen.go).
examples/standalone/ shows the core idea: a
plain Go text/template (not Helm) converted to CUE. It includes a
small Go program that executes the template, alongside the CUE
equivalent produced by helm2cue template. See the
standalone README for details.
examples/simple-app/helm/ is a standard
Helm chart. The generated CUE output is in
examples/simple-app/cue/.
Render all templates:
helm template my-release ./examples/simple-app/helmRender a single template:
helm template my-release ./examples/simple-app/helm -s templates/configmap.yamlhelm2cue chart ./examples/simple-app/helm ./examples/simple-app/cueThis produces a ready-to-use CUE module:
simple-app/cue/
cue.mod/module.cue # module: "helm.local/simple-app"
deployment.cue # deployment: { ... }
service.cue # service: { ... }
configmap.cue # configmap: { ... }
helpers.cue # _simple_app_fullname, _simple_app_labels, etc.
values.cue # #values: { name: *"app" | _, ... } (schema)
data.cue # @extern(embed) for values.yaml and release.yaml
context.cue # #chart (from Chart.yaml)
results.cue # results: [configmap, deployment, service]
values.yaml # copied from chart
release.yaml # empty placeholder for @embed
Export a single resource:
cd examples/simple-app/cue
cue export . -t release_name=my-release -e configmap --out yamlExport all resources as a multi-document YAML stream (like helm template):
cd examples/simple-app/cue
cue export . -t release_name=my-release --out text -e 'yaml.MarshalStream(results)'In chart mode, the tool:
- Parses
Chart.yamlto extract chart metadata. - Collects all helper templates (
.tplfiles) from the chart and its subchart dependencies (e.g.charts/common/templates/*.tpl), and parses them into a shared template tree. - Converts each template (
.yaml/.ymlintemplates/) to CUE using the shared helpers. Templates that fail conversion are skipped with a warning. - Merges results across all templates to produce:
values.cue— a#valuesschema derived from all field references and defaults across all templatescontext.cue— definitions for.Release,.Chart,.Capabilities, and.Template, with concrete values fromChart.yamlwhere availablehelpers.cue— all helper definitions, plus_nonzeroif any template uses conditions- Per-template
.cuefiles — each template body wrapped in a uniquely-named top-level field (e.g.deployment: { ... }) results.cue— aresultslist referencing all template fields, for use withyaml.MarshalStream(results)to produce a multi-document YAML stream likehelm template
- Copies
values.yamlinto the output directory for use at export time.
The core of the project: each template is converted by walking its Go
text/template AST and emitting CUE directly.
- Template parsing — the template and helpers are parsed using Go's
text/template/parse.{{ define }}blocks are converted to CUE hidden fields (e.g._myapp_fullname: "\(#release.Name)-\(#chart.Name)"). - Direct CUE emission — the AST is walked node by node. Text nodes are
parsed line-by-line as YAML fragments, tracking indent context via a frame
stack. Template actions (e.g.
{{ .Values.x }}) are emitted as CUE expressions (e.g.#values.x). Control structures (if,range) emit CUE guards and comprehensions.
CUE is not whitespace-sensitive and { A } = A for any A, so CUE blocks
can be freely emitted around content without affecting semantics. This
eliminates the need for a YAML parser intermediary.
Several functions are handled by the core converter rather than as
configurable pipeline functions. Two are Go text/template builtins:
printf and print (format-string rewriting that does not fit
the PipelineFunc interface). The rest are Sprig/Helm functions that
are core-handled because they shape the structure and semantics of the
generated CUE: default (tracked across all templates to build the
#values schema with CUE defaults), include (resolves named
helper templates via the shared template graph), required (emits
CUE constraint annotations), and ternary (conditional expressions).
In template mode (pure text/template) only the Go builtins are
enabled; the Sprig/Helm functions are rejected. In chart mode all
core-handled functions are available.
Helm built-in objects are mapped to CUE definitions:
| Helm Object | CUE Definition |
|---|---|
.Values |
#values |
.Release |
#release |
.Chart |
#chart |
.Capabilities |
#capabilities |
.Template |
#template |
.Files |
#files |
The generated CUE includes utility definitions for operations that CUE's standard library does not yet provide as builtins:
| Helper | Purpose |
|---|---|
_nonzero |
Tests whether a value is "truthy" (non-zero, non-empty, non-null), matching Go text/template semantics |
_semverCompare |
Evaluates simple semver operator constraints (>=, <=, >, <, !=, =) against a version string |
_trunc |
Truncates a string to N runes, matching Helm's trunc semantics |
_last |
Extracts the last element of a list |
_compact |
Removes empty strings from a list |
_uniq |
Removes duplicate elements from a list |
These are natural candidates for CUE standard library builtins and will be removed once those exist.
| Helm Template Construct | CUE Equivalent | Status |
|---|---|---|
| Plain YAML (no directives) | CUE struct/scalar literal | Done |
{{ .Values.x }} |
#values.x reference |
Done |
{{ .Values.x | default "v" }} |
Default on #values declaration: x: _ | *"v" |
Done |
{{ .Values.x | quote }} |
String interpolation: "\(#values.x)" |
Done |
{{ .Values.x | squote }} |
Single-quote interpolation: "'\(#values.x)'" |
Done |
{{ if .Values.x }}...{{ end }} |
CUE if guard (condition fields typed _ | *null) |
Done |
{{ if .Values.x }}...{{ else }}...{{ end }} |
Two if guards: if cond { } and if !cond { } |
Done |
{{ if eq/ne/lt/gt/le/ge a b }} |
Comparison: a == b, a != b, etc. |
Done |
{{ if and/or a b }} |
Logical: cond(a) && cond(b), cond(a) || cond(b) |
Done |
{{ if not .Values.x }} |
Negation: !(cond) |
Done |
{{ if empty .Values.x }} |
Emptiness check: !(cond) |
Done |
{{ range .Values.x }}...{{ end }} |
List comprehension: for _, v in #values.x { ... } |
Done |
{{ range $k, $v := .Values.x }}...{{ end }} |
Map comprehension: for k, v in #values.x { (k): v } |
Done |
{{ $var := .Values.x }} |
Local variable: tracked and inlined | Done |
{{ printf "%s-%s" .Values.a .Values.b }} |
String interpolation: "\(#values.a)-\(#values.b)" |
Done |
{{ print .Values.a "-" .Values.b }} |
String interpolation: "\(#values.a)-\(#values.b)" |
Done |
{{ required "msg" .Values.x }} |
Reference with comment: #values.x // required: "msg" |
Done |
{{- ... -}} (whitespace trim) |
Handled by Go's template parser | Done |
{{/* comment */}} |
CUE comment: // ... |
Done |
{{ define "name" }}...{{ end }} |
CUE hidden field: _name: <expr> |
Done |
{{ include "name" . }} |
Reference to hidden field: _name |
Done |
{{ include "name" .Values.x }} |
_name & {#arg: #values.x, _} with schema propagation |
Done |
{{ include "name" (dict ...) }} |
Reference with dict context tracking | Done |
{{ include (print ...) . }} |
Dynamic lookup: _helpers[nameExpr] |
Done |
{{ if include "name" . }} |
Condition with _nonzero wrapping include result |
Done |
{{ template "name" . }} |
Reference to hidden field: _name |
Done |
{{ with .Values.x }}...{{ end }} |
CUE if guard with dot rebinding |
Done |
{{ with .Values.x }}...{{ else }}...{{ end }} |
Two if guards; with branch rebinds dot, else does not |
Done |
{{ tpl .Values.x . }} |
yaml.Unmarshal(template.Execute(#values.x, _tplContext)) |
Done |
{{ tpl (toYaml .Values.x) . }} |
Wraps value in yaml.Marshal(...) before template.Execute |
Done |
{{ lookup ... }} |
Not supported (descriptive error) | Error |
| Sprig Function | CUE Equivalent | Import |
|---|---|---|
toYaml, toJson, toString, toRawJson, toPrettyJson |
No-op (CUE values are structural) | — |
fromYaml, fromJson |
No-op | — |
nindent, indent |
No-op (CUE handles indentation) | — |
upper |
strings.ToUpper(expr) |
strings |
lower |
strings.ToLower(expr) |
strings |
title |
strings.ToTitle(expr) |
strings |
trim |
strings.TrimSpace(expr) |
strings |
trimPrefix |
strings.TrimPrefix(expr, arg) |
strings |
trimSuffix |
strings.TrimSuffix(expr, arg) |
strings |
contains |
strings.Contains(expr, arg) |
strings |
hasPrefix |
strings.HasPrefix(expr, arg) |
strings |
hasSuffix |
strings.HasSuffix(expr, arg) |
strings |
replace |
strings.Replace(expr, old, new, -1) |
strings |
trunc |
strings.SliceRunes(expr, 0, n) |
strings |
b64enc |
base64.Encode(null, expr) |
encoding/base64 |
b64dec |
base64.Decode(null, expr) |
encoding/base64 |
int, int64 |
int & expr |
— |
float64 |
number & expr |
— |
atoi |
strconv.Atoi(expr) |
strconv |
ceil |
math.Ceil(expr) |
math |
floor |
math.Floor(expr) |
math |
round |
math.Round(expr) |
math |
add |
(expr + arg) |
— |
sub |
(arg - expr) |
— |
mul |
(expr * arg) |
— |
div |
div(arg, expr) |
— |
mod |
mod(arg, expr) |
— |
join |
strings.Join(expr, arg) |
strings |
sortAlpha |
list.SortStrings(expr) |
list |
concat |
list.Concat(expr) |
list |
first |
expr[0] |
— |
append |
expr + [arg] |
— |
regexMatch |
regexp.Match(pattern, expr) |
regexp |
regexFind |
regexp.Find(pattern, expr) |
regexp |
regexReplaceAll |
regexp.ReplaceAll(pattern, expr, repl) |
regexp |
base |
path.Base(expr, path.Unix) |
path |
dir |
path.Dir(expr, path.Unix) |
path |
ext |
path.Ext(expr, path.Unix) |
path |
sha256sum |
hex.Encode(sha256.Sum256(expr)) |
crypto/sha256, encoding/hex |
ternary |
[if cond {trueVal}, falseVal][0] |
— |
list |
[arg1, arg2, ...] (list literal) |
— |
last |
(_last & {#in: expr}).out |
— |
uniq |
(_uniq & {#in: expr}).out |
list |
compact |
(_compact & {#in: expr}).out |
— |
dict |
{key: val, ...} (struct literal) |
— |
get |
map.key or map[key] |
— |
hasKey |
(_nonzero & {#arg: map.key, _}) |
— |
keys |
[ for k, _ in expr {k}] |
— |
values |
[ for _, v in expr {v}] |
— |
coalesce |
[if nz(a) {a}, ..., last][0] |
— |
semverCompare |
(_semverCompare & {#constraint: ..., #version: ...}).out |
strings, strconv |
max |
list.Max([a, b]) |
list |
min |
list.Min([a, b]) |
list |
set |
Not supported (descriptive error) | — |
merge, mergeOverwrite |
Not supported (descriptive error) | — |
The following template constructs and functions are not yet converted. Templates using them are skipped with a warning. The gaps are grouped roughly by how often they appear in real charts (kube-prometheus-stack is a good stress test).
lookup— runtime Kubernetes API lookups have no static CUE equivalentindexin conditions —{{ if (index .Values "key").field }}uses bracket-style map access which the condition parser does not handle- Method calls in conditions — e.g.
.Capabilities.APIVersions.Has "autoscaling/v2"(method-style calls on context objects)
until—{{ range $i, $e := until N }}generates an integer sequence; neitheruntilnor the two-variable range form are supported yetsplitList— split a string into a list by separatoromit— return a dict with specified keys removeddig— nested map traversal with a default ({{ dig "key" "subkey" "fallback" .Values }})mustRegexReplaceAllLiteral— literal (non-regex) variant ofregexReplaceAll- Crypto:
derivePassword,genCA(runtime crypto operations) - Date:
now,date,dateModify(runtime date operations)
Some functions that are handled have gaps in specific usage patterns:
defaultwith non-literal fallback —defaultworks when the fallback is a literal ("x",true,80), a field reference (.Values.x), or aprintf/printcall, but fails when it is anincludecall or a keyword (list)- Functions in sub-expression position — when a function call
appears nested inside another expression (e.g. as an argument to
default), onlyprintf,print,include, andtplare recognised. Other functions in that position produce an "unsupported pipe node" error. Each function requires an explicit case innodeToExpr ternary— the function is recognised but fails in some contexts (e.g. when used in webhook configurations)
Some templates convert without error but produce CUE that does not parse. These are structural issues in how the converter maps YAML+template interactions to CUE, not missing function support. The 17 errors across integration tests (2 nginx, 15 kube-prometheus-stack) break down as follows:
- YAML sibling keys nested into lists (~6 errors) — when a YAML
list item has sibling keys (
apiGroups:,resources:,verbs:), the converter nests subsequent keys inside the first key's list instead of making them struct fields within the list element toYamlsplicing into list context (~5 errors) —toYamloutput inserted mid-list cannot be tracked as list vs struct context, producingmissing ',' in list literal- Multi-part string interpolation (2 errors) — complex image
strings like
{{ $reg }}/{{ .repo }}:{{ .tag }}@sha256:{{ .sha }}fail to combine into a single CUE string interpolation, producing stray tokens - Range body boundary (2 errors) — range over content that
produces multiple YAML documents closes the
forcomprehension too early, leaving trailing content outside the loop
Despite its name, helm2cue is narrowly focused on one question: how do you
convert a Go text/template to CUE? Helm charts are a convenient test case
because they exercise most of text/template's features, but the goal is
general-purpose template conversion. The approach should generalise to any use
of text/template that targets structured output.
Helm is much more than its templates: it is a popular packaging and distribution mechanism with lifecycle management, repository hosting, dependency resolution, and a large ecosystem of published charts. None of that is in scope here. helm2cue does not aim to replace Helm or provide a migration path away from it — those are fundamentally different problems.
The wider problem of "how do you manage Kubernetes configuration with CUE instead of Helm" is tackled by several existing projects, such as:
- Timoni — a package manager for Kubernetes powered by CUE. Timoni replaces Helm's Go templates with CUE's type system and validation, and distributes modules as OCI artifacts.
- Holos — a platform manager that uses CUE to configure Helm charts, Kustomize bases, and plain manifests holistically, rendering fully hydrated manifests for tools like ArgoCD or Flux to apply.
- cuelm — experiments with a pure CUE implementation of Helm, part of the Hofstadter ecosystem.
These projects address the end-to-end workflow that Helm provides. If you are looking for a CUE-native alternative to Helm for managing Kubernetes deployments, those projects are worth exploring.
There is also a proposal within Helm itself
to adopt CUE for values validation, replacing the current JSON Schema support.
That work is complementary: it would use CUE to validate and default chart
values while keeping Go templates for rendering. helm2cue explores the other
side of the coin — converting the templates themselves to CUE. If the Helm
proposal progresses, the #values schema that helm2cue derives from template
defaults could potentially serve as a starting point for a chart's CUE
validation schema.
helm-schema takes a different approach
to the values schema problem: it derives a JSON Schema from annotations in
values.yaml itself, rather than walking the templates. The two approaches are
complementary — helm-schema captures what a chart author explicitly documents,
while helm2cue infers the schema from how values are actually used in templates.
Tests are run against Helm v4.1.1 and CUE v0.16.0-alpha.2.
Core test cases live in testdata/core/*.txtar and are run by
TestConvertCore. They prove the text/template to CUE converter works
generically, without Helm-specific configuration. Each file uses the
txtar format with these
sections:
-- input.yaml --— the template input (required)-- output.cue --— the expected CUE output (required; generated via-update)-- _helpers.tpl --— helper templates containing{{ define }}blocks (optional)-- error --— expected error substring (negative test; mutually exclusive withoutput.cue)
These tests use a test-specific config with a single context object
("input" mapped to #input) and no pipeline functions. Templates
reference .input.* instead of .Values.* and are validated with Go's
text/template/parse — not helm template. This exercises the core
features (YAML emission, field references, if/else, range, printf,
variables) without coupling to Helm names or Sprig functions.
Helm test cases live in testdata/*.txtar and are run by TestConvert.
Each file uses the same txtar format with additional optional sections:
-- values.yaml --— Helm values to use during validation-- helm_output.yaml --— expected rendered output fromhelm template-- error --— expected error substring (negative test; see below)
Each test case:
- Runs
helm templateon the input to verify it is a valid Helm template. Ifvalues.yamlis present it is used as chart values. Ifhelm_output.yamlis present, the rendered output is compared against it. - Runs
Convert()withHelmConfig()which produces CUE (including#values: _etc. declarations) and validates it compiles. - Compares the CUE output against the
output.cuegolden file. - If both
values.yamlandhelm_output.yamlare present, runscue exporton the generated CUE with values and any needed context objects (#release,#chart, etc.) and semantically compares the result with the helm template output. This verifies that the CUE, when given the same values, produces the same data as Helm.
If -- error -- is present instead of -- output.cue --, the test
expects Convert() to fail and checks that the error message contains
the given substring. This is used to verify that unsupported functions
(merge, set, lookup) and invalid argument counts produce
clear error messages. Error tests are named error_*.txtar by
convention.
Integration tests live in integration_test.go and are skipped with -short.
TestIntegration exercises single-template conversion by iterating over
chart directories under testdata/charts/ (simple-app, dup-helpers).
TestConvertChartIntegration pulls real-world charts at test time via
helm pull (requires helm in PATH) and verifies that ConvertChart
produces valid CUE output that passes cue vet and cue export:
- nginx — bitnami/nginx v22.0.7
- kube-prometheus-stack — prometheus-community v82.2.1
TestConvertChart tests chart-level conversion on simple-app and
dup-helpers, verifying that the output is a valid CUE module that
passes cue vet and cue export.
CLI tests live in testdata/cli/*.txtar and are run by TestCLI. They use
testscript
to exercise the helm2cue binary as a whole — argument parsing,
stdin/stdout/stderr routing, exit codes, and error formatting — without
building a separate binary (the command runs in-process via TestMain).
Each .txtar file is a self-contained scenario that invokes helm2cue
with exec and asserts on stdout/stderr content. Current coverage
includes:
templatesubcommand: file input, file with helper, stdin inputtemplateerrors: multiple template files, non-existent file, unsupported Sprig/Helm functioncharterrors: missing arguments, non-existent chart directoryversionsubcommand: prints version information- Usage/unknown command: no arguments, unknown subcommand
# Run all tests (including integration)
go test ./...
# Run unit tests only (skip integration)
go test -short ./...
# Run core converter tests only (no Helm dependency)
go test -run TestConvertCore -v
# Run Helm-specific tests only
go test -run TestConvert -v
# Run integration tests only
go test -run TestIntegration -v
# Run chart conversion tests
go test -run TestConvertChart -v
# Run CLI end-to-end tests
go test -run TestCLI -v
# Update golden files after intentional changes to conversion logic
go test -update