Skip to content

cue-exp/helm2cue

Repository files navigation

helm2cue

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.

Commands

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.

Examples

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).

Standalone template

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.

Helm chart (simple-app)

examples/simple-app/helm/ is a standard Helm chart. The generated CUE output is in examples/simple-app/cue/.

Rendering the chart with Helm

Render all templates:

helm template my-release ./examples/simple-app/helm

Render a single template:

helm template my-release ./examples/simple-app/helm -s templates/configmap.yaml

Converting the chart to CUE

helm2cue chart ./examples/simple-app/helm ./examples/simple-app/cue

This 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

Exporting from the CUE module

Export a single resource (each template field is a list of YAML documents, so [0] extracts the first):

cue export ./examples/simple-app/cue -t release_name=my-release -e configmap[0] --out yaml

Export all resources as a multi-document YAML stream (like helm template):

cue export ./examples/simple-app/cue -t release_name=my-release --out text -e 'yaml.MarshalStream(results)'

How It Works

Chart-level conversion

In chart mode, the tool:

  1. Parses Chart.yaml to extract chart metadata.
  2. Collects all helper templates (.tpl files) from the chart and its subchart dependencies (e.g. charts/common/templates/*.tpl), and parses them into a shared template tree.
  3. Converts each template (.yaml/.yml in templates/) to CUE using the shared helpers. Templates that fail conversion are skipped with a warning.
  4. Merges results across all templates to produce:
    • values.cue — a #values schema derived from all field references and defaults across all templates
    • context.cue — definitions for .Release, .Chart, .Capabilities, and .Template, with concrete values from Chart.yaml where available
    • helpers.cue — all helper definitions, plus _nonzero if any template uses conditions
    • Per-template .cue files — each template body wrapped in a uniquely-named top-level field (e.g. deployment: { ... })
    • results.cue — a results list referencing all template fields, for use with yaml.MarshalStream(results) to produce a multi-document YAML stream like helm template
  5. Copies values.yaml into the output directory and embeds it into #values via CUE's @embed directive. Because #values is a CUE definition (with required fields, optional fields, and structural constraints), CUE validates the actual values.yaml against the inferred schema automatically — no separate validation step is needed.

A side effect of converting a Helm chart is that helm2cue derives an implied schema for values.yaml from how values are used across all templates. If a template accesses .Values.image.repository, that field appears in the schema as required. If a template uses {{ .Values.debug | default false }}, the field appears as optional with a CUE default. The examples/simple-app/ example illustrates this: the chart's values.yaml is a plain YAML file, but the generated values.cue contains a #values definition with typed fields — and data.cue embeds the actual values.yaml into that definition so CUE validates it on every evaluation.

Template conversion

The core of the project: each template is converted by walking its Go text/template AST and emitting CUE directly.

  1. Template parsing — the template and helpers are parsed using Go's text/template/parse. {{ define }} blocks become CUE hidden fields (e.g. _myapp_fullname: "\(#release.Name)-\(#chart.Name)"). Helper bodies are recorded but not converted until their first {{ include }}/{{ template }} call site, which determines whether the helper produces structured YAML (CUE struct) or plain text (CUE string) based on the YAML context and pipeline functions. See doc.go for a detailed discussion of the fundamental ambiguity in helper type inference and the approach used to resolve it.
  2. 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

Helper definitions

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. Has an out field: (_nonzero & {#arg: expr}).out
_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
_typeof Returns the CUE type name of a value, matching Sprig's typeOf semantics
_dig Nested map traversal with a default value, matching Sprig's dig
_omit Returns a struct with specified keys removed, matching Sprig's omit
_merge Shallow key-level merge of two structs where the first argument wins, matching Sprig's merge
_mergeOverwrite Shallow key-level merge of two structs where the last argument wins, matching Sprig's mergeOverwrite

These are natural candidates for CUE standard library builtins and will be removed once those exist.

Conversion Mapping

Template constructs

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}).out 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 & {#arg: ...}).out 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
{{ dig "a" "b" "default" .Values.x }} (_dig & {#path: ["a","b"], #default: "default", #map: #values.x}).out Done
{{ omit .Values.x "key" }} (_omit & {#arg: #values.x, #omit: ["key"]}).out Done
{{ kindIs "string" .Values.x }} Kind test condition: (#values.x & string) != _|_ Done
{{ typeIs "string" .Values.x }} Type test condition: (#values.x & string) != _|_ Done
{{ typeOf .Values.x }} (_typeof & {#arg: #values.x}).out Done
{{ lookup ... }} Not supported (descriptive error) Error

Pipeline functions (Sprig, chart mode only)

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
splitList strings.Split(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
mustUniq (_uniq & {#in: expr}).out (alias for uniq) list
compact (_compact & {#in: expr}).out
dict {key: val, ...} (struct literal)
get map.key or map[key]
hasKey Literal key: (_nonzero & {#arg: map.key}).out; dynamic key: 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 (_merge & {#a: dst, #b: src}).out (first arg wins)
mergeOverwrite (_mergeOverwrite & {#a: dst, #b: src}).out (last arg wins)

CUE Language Experiments Mode

The --experiments flag enables experimental CUE output that uses in-progress CUE language features (try, explicitopen). Generated files include @experiment(try,explicitopen) attributes. This mode is under active development; see the tracking issues below.

Not Yet Implemented

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).

Template constructs

  • lookup — runtime Kubernetes API lookups have no static CUE equivalent

Sprig functions not yet converted

  • mustRegexReplaceAllLiteral — literal (non-regex) variant of regexReplaceAll
  • Crypto: derivePassword, genCA (runtime crypto operations)
  • Date: now, date, dateModify (runtime date operations)

Core-handled function gaps

Some functions that are handled have gaps in specific usage patterns:

  • Functions in sub-expression position — when a function call appears nested inside another expression (e.g. as an argument to default), only a subset of functions is recognised. Other functions in that position produce an "unsupported pipe node" error. Each function requires an explicit case in nodeToExpr

CUE output validation failures

Some templates convert without error but produce CUE that does not fully validate at export time. cue vet (structural validation) now passes for kube-prometheus-stack; nginx has one remaining cue vet error. cue export (full evaluation) surfaces additional issues because it requires concrete values that the unconstrained #values schema cannot provide.

nginx (14/16 templates converted, 2 skipped due to genCA): cue vet reports two errors: strconv.Atoi fails on a capability-version string ("25-0") in a helper that parses .Capabilities.KubeVersion.Minor (a data issue, not a converter bug), and the _common_resources_preset helper produces a runtime error because its hasKey dynamic key lookup evaluates #arg.type as a CUE field reference rather than a dict key.

kube-prometheus-stack (171/171 templates converted): cue vet passes cleanly. cue export fails because many templates use tpl (converted to text/template.Execute) which requires concrete values to evaluate. With unconstrained #values the template engine cannot render, producing "cannot convert non-concrete value" errors. This primarily affects grafana dashboard configmap labels (dynamic keys from tpl calls) and servicemonitor relabeling fields.

Key Issues

The following issues track fundamental design questions that affect how templates are converted. They are not simple bugs — they represent tensions in the mapping from Go text/template semantics to CUE, and may require ongoing refinement as more real-world charts are tested.

  • #93 — Improve readability of generated CUE — The generated CUE is correct but not always idiomatic or readable. Helper call sites like (_nonzero & {#arg: expr}).out are verbose, schema types are broader than necessary, and the overall style should feel natural to CUE users. Tracks readability improvements as a cross-cutting concern.

  • #109 — Improve helper type inference determinism — Helper type inference currently uses first-encounter-wins for weak signals, making conversion order-dependent. Tracks the Phase 0.5 approach for global call-site consensus. See doc.go for the full design discussion.

CUE language experiments

Several in-progress CUE language experiments could improve the generated output. Each issue tracks exploration of a specific proposal — evaluating how it would change helm2cue's output and providing feedback upstream:

Related Projects

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.

Testing

Tests are run against Helm v4.1.1 and CUE v0.16.0.

Core converter tests

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 with output.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-specific tests

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 from helm template
  • -- error -- — expected error substring (negative test; see below)
  • -- broken -- — marks a known converter bug (see below)
  • -- experiments_output.cue -- — experiments mode output (see below)

Each test case:

  1. Runs helm template on the input to verify it is a valid Helm template. If values.yaml is present it is used as chart values. If helm_output.yaml is present, the rendered output is compared against it.
  2. Runs Convert() with HelmConfig() which produces CUE (including #values: _ etc. declarations) and validates it compiles.
  3. Compares the CUE output against the output.cue golden file.
  4. If both values.yaml and helm_output.yaml are present, runs cue export on 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.

Error tests

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 (set, lookup) and invalid argument counts produce clear error messages. Error tests are named error_*.txtar by convention.

Broken tests

If -- broken -- is present, the test marks a known converter bug. helm template validation still runs against helm_output.yaml, then Convert() is expected to error with the broken substring. When the bug is fixed, -- broken -- is replaced with -- output.cue --. This keeps the test in the verified directory from the start so the fix produces a clean diff.

Experiments mode output

Tests can opt in to experiments mode by adding an -- experiments_output.cue -- section. When present, the test runs Convert() a second time with Experiments: true and compares the output against this section. Round-trip validation against helm template is also performed for the experiments output.

Tests opt in gradually — only add -- experiments_output.cue -- when ready to track experiments output for that case. Use go test -run <pattern> -update to populate both output.cue and experiments_output.cue in one pass. Currently experiments mode produces the same output as normal mode; as experiment-aware patterns are implemented, opted-in tests will capture the differences.

Noverify tests

Tests in testdata/noverify/*.txtar are run by TestConvertNoVerify. These must not contain helm_output.yaml. Each file must have a txtar comment explaining why Helm comparison is not possible (e.g. Helm renders Go format output like map[...], uses undefined helpers, or produces output that cannot be meaningfully compared).

Integration tests

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 end-to-end tests

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:

  • template subcommand: file input, file with helper, stdin input
  • template errors: multiple template files, non-existent file, unsupported Sprig/Helm function
  • chart subcommand: full chart conversion with helpers, subcharts, values schema inference, and output file comparison
  • chart errors: missing arguments, non-existent chart directory
  • Bug reproductions: issue-specific tests (e.g. issue85_*.txtar, issue86_*.txtar) that exercise chart-level conversion for reported bugs
  • version subcommand: prints version information
  • Usage/unknown command: no arguments, unknown subcommand

Workflow

# 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

About

An experiment in converting Go text/template to CUE, using Helm charts as the driving example

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors