Skip to content

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

Notifications You must be signed in to change notification settings

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:

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

Export 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)'

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 for use at export time.

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 are converted to CUE hidden fields (e.g. _myapp_fullname: "\(#release.Name)-\(#chart.Name)").
  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
_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.

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, _} 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

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

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
  • index in 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)

Sprig functions not yet converted

  • until{{ range $i, $e := until N }} generates an integer sequence; neither until nor the two-variable range form are supported yet
  • splitList — split a string into a list by separator
  • omit — return a dict with specified keys removed
  • dig — nested map traversal with a default ({{ dig "key" "subkey" "fallback" .Values }})
  • 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:

  • default with non-literal fallback — default works when the fallback is a literal ("x", true, 80), a field reference (.Values.x), or a printf/print call, but fails when it is an include call 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), only printf, print, include, and tpl are recognised. Other functions in that position produce an "unsupported pipe node" error. Each function requires an explicit case in nodeToExpr
  • ternary — the function is recognised but fails in some contexts (e.g. when used in webhook configurations)

CUE output validation failures

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
  • toYaml splicing into list context (~5 errors) — toYaml output inserted mid-list cannot be tracked as list vs struct context, producing missing ',' 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 for comprehension too early, leaving trailing content outside the loop

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-alpha.2.

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)

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

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 errors: missing arguments, non-existent chart directory
  • 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