helmtk is a toolkit for Helm chart maintainers that provides a structured template language, testing framework, and automated chart migration tools. It eliminates the common pitfalls of writing Helm charts with YAML and Go templates by providing a structured language that compiles into Helm templates.

Why helmtk?

Traditional Helm charts combine significant whitespace in YAML with text-based Go templates, leading to:

  • Complex indentation management with {{- and | nindent
  • Manual string quoting with | quote
  • Object conversion hassles with | toYaml
  • Confusing scope with the changing meaning of .
  • Unexpected type coercion (e.g. yes becoming a boolean)

helmtk solves these problems with:

  • No significant whitespace - indentation is for readability only
  • Clear object delimiters and data structures
  • Structured output instead of text templating
  • Compilation to Helm charts
  • Built-in testing framework
  • Automatic migration from existing charts

Commands

Usage:
  helmtk [command]

Available Commands:
  compile     Compile a helmtk directory to a Helm chart
  completion  Generate the autocompletion script for the specified shell
  decompile   Decompile a Helm chart template to helmtk syntax
  eval        Evaluate a helmtk template to YAML
  help        Help about any command
  test        Run JavaScript test suites for Helm charts

VSCode Extension

The helmtk VSCode extension provides syntax highlighting and language support for HTKL files in Visual Studio Code.

HTKL Language

HTKL (helmtk language) is a structured template language. Unlike traditional text-based templating, HTKL emits structured data, eliminating whitespace and quoting issues.

The HTKL language is open-source and available as a Go library at https://github.com/helmtk/htkl

{
  "apiVersion": "v1"
  "kind": "Service"
  "metadata": {
    "name": Release.Name
    "namespace": Release.Namespace
  }
  "spec": {
    "ports": [
      {
        "port": Values.service.port
        "targetPort": "http"
        "name": "http"
      }
    ]
  }
}

Data Types

Objects

Objects are defined with curly braces. Keys can be quoted or unquoted strings.

{
  "key": "value"
  unquoted: "also works"
  "nested": {
    "object": true
  }
}

Arrays

Arrays are defined with square brackets and can contain any type of value.

[
  "string"
  42
  true
  { "object": "value" }
]

Strings

Strings are double-quoted. Multi-line strings are triple-double-quoted.

"double quoted"

"""
multiline string
across multiple lines
"""

Numbers and Booleans

42
3.14159
true
false
null

Control Flow

Conditionals

Use if statements to conditionally include values in objects and arrays.

{
  "ports": [
    {
      "containerPort": 8000
      "name": "http"
    }

    if Values.debug do
      {
        "containerPort": 5005
        "name": "debug"
      }
    end
  ]
}

Loops

Iterate over arrays and objects with for loops.

# Iterate over array
{
  "env": [
    for idx, item in Values.env do
      {
        "name": item.name
        "value": item.value
      }
    end
  ]
}

# Iterate over object
{
  "labels": {
    for key, value in Values.labels do
      key: value
    end
  }
}

Functions

Built-in Functions

HTKL provides many built-in functions compatible with Helm/Sprig:

# String functions
upper("hello")              # "HELLO"
lower("WORLD")              # "world"
trim("  spaces  ")          # "spaces"
trimAll("$", "$hello$")     # "hello"
trimSuffix("-", "hello-")   # "hello"
trimPrefix("pre-", "pre-fix") # "fix"
quote("value")              # "\"value\""
squote("value")             # "'value'"
nindent(2, "text")          # "\n  text"
indent(2, "text")           # "  text"
contains("ell", "hello")    # true
trunc(3, "hello")           # "hel"
replace("old", "new", s)    # replace occurrences
printf("%s: %d", "a", 1)    # "a: 1"
repeat(3, "ab")             # "ababab"
substr(0, 3, "hello")       # "hel"
nospace("h e l l o")        # "hello"
initials("John Doe")        # "JD"
title("hello world")        # "Hello World"
untitle("Hello World")      # "hello world"
snakecase("helloWorld")     # "hello_world"
camelcase("hello_world")    # "helloWorld"
kebabcase("helloWorld")     # "hello-world"
swapcase("Hello")           # "hELLO"
shuffle("hello")            # random shuffle
wrap(80, text)              # wrap at 80 chars
wrapWith(80, "\n", text)    # wrap with custom break
abbrev(5, "hello world")    # "he..."
abbrevboth(5, 10, "hello")  # abbreviate both ends

# String/Array functions
split(".", "a.b.c")         # {"_0":"a","_1":"b","_2":"c"}
splitList(".", "a.b.c")     # ["a", "b", "c"]
splitn(".", 2, "a.b.c")     # ["a", "b.c"]
join(",", list)             # "a,b,c"
hasPrefix("pre", "prefix")  # true
hasSuffix("fix", "suffix")  # true
sortAlpha(list)             # alphabetically sorted list

# Conversion functions
toJson(object)              # JSON string
toPrettyJson(object)        # pretty-printed JSON
toRawJson(object)           # JSON without HTML escaping
fromJson(jsonStr)           # parse JSON to object
toYaml(object)              # YAML string
fromYaml(yamlStr)           # parse YAML to object
toString(42)                # "42"
toStrings(list)             # convert list items to strings
atoi("123")                 # 123 (string to int)
int(3.14)                   # 3
int64(42)                   # 64-bit integer
float64(42)                 # 42.0

# Utility functions
default("fallback", val)    # return fallback if val is empty
empty(val)                  # true if val is empty
coalesce(v1, v2, v3)        # first non-empty value
ternary("yes", "no", cond)  # "yes" if cond, else "no"
deepCopy(object)            # deep copy of object

# Math functions
add(1, 2)                   # 3
add1(5)                     # 6
sub(5, 3)                   # 2
mul(2, 3)                   # 6
div(10, 2)                  # 5
mod(10, 3)                  # 1
max(1, 2, 3)                # 3
min(1, 2, 3)                # 1
round(3.5, 0, 1)            # 4
floor(3.7)                  # 3
ceil(3.2)                   # 4
biggest(1, 2, 3)            # 3

# List functions
list(1, 2, 3)               # [1, 2, 3]
first(list)                 # first element
last(list)                  # last element
initial(list)               # all but last
rest(list)                  # all but first
append(list, val)           # append to list
prepend(list, val)          # prepend to list
concat(list1, list2)        # concatenate lists
reverse(list)               # reverse list
uniq(list)                  # unique elements
without(list, val)          # remove val from list
has(list, val)              # true if list contains val
slice(list, 1, 3)           # slice from index 1 to 3
chunk(2, list)              # split into chunks of 2
compact(list)               # remove empty elements
deepEqual(a, b)             # deep equality check
len(list)                   # length of list/string/object

# Dict/Object functions
dict("a", 1, "b", 2)        # {"a": 1, "b": 2}
keys(object)                # list of keys
values(object)              # list of values
pick(object, "a", "b")      # pick keys a, b
omit(object, "a")           # omit key a
merge(obj1, obj2)           # merge objects (obj1 wins)
mergeOverwrite(obj1, obj2)  # merge (obj2 wins)
get(object, "key")          # get value by key
set(object, "key", val)     # set key to val
unset(object, "key")        # remove key
hasKey(object, "key")       # true if key exists
pluck("key", obj1, obj2)    # pluck key from objects
dig("a", "b", object)       # nested access: object.a.b

# Encoding functions
b64enc("hello")             # base64 encode
b64dec("aGVsbG8=")          # base64 decode

# Type testing functions
kindOf(val)                 # "string", "map", etc.
kindIs("string", val)       # true if kind matches
typeOf(val)                 # Go type name
typeIs("string", val)       # true if type matches

# Regex functions
regexMatch("^h.*o$", "hello")           # true
regexFind("l+", "hello")                # "ll"
regexFindAll("l", "hello", -1)          # ["l", "l"]
regexReplaceAll("l", "hello", "L")      # "heLLo"
regexReplaceAllLiteral("l", "hi", "L")  # literal replace
regexSplit("l+", "hello", -1)           # ["he", "o"]

Templates

Defining Templates

Templates are reusable blocks that can be included in multiple files.

define("common.labels") do
  {
    "app.kubernetes.io/name": Chart.Name
    "app.kubernetes.io/instance": Release.Name
  }
end

define("selector.labels") {
  "app": Release.Name
  "component": "web"
}

Including Templates

{
  "metadata": {
    "labels": include("common.labels")
  }
  "spec": {
    "selector": {
      "matchLabels": include("selector.labels")
    }
  }
}

Values & Variables

Accessing Values

Access values from values.yaml using the Values object.

Values.replicaCount
Values.image.repository
Values.service.port

Built-in Variables

  • Values - Values from values.yaml and --set flags
  • Chart - Chart metadata from Chart.yaml
  • Release - Release information (Name, Namespace, Service, etc.)
  • Files - Access to non-template files in the chart
  • Capabilities - Kubernetes cluster capabilities

Local Variables

let port = Values.service.port
let name = Release.Name + "-service"

{
  "metadata": {
    "name": name
  }
  "spec": {
    "ports": [
      { "port": port }
    ]
  }
}

Decompiler

The helmtk decompiler is a tool that automatically converts existing Helm charts into helmtk format. Using an LLM and other supporting tools, it:

  • reads existing Helm templates
  • writes a test suite for the templates
  • ensures the test suite passes against the Helm templates
  • converts the templates to helmtk
  • ensures the test suite passes against the new helmtk templates
  • ensures the test suite passes when the helmtk templates are compiled to helm

The decompiler doesn't overwrite anything in the existing chart - everything is written to a new helmtk directory.

The decompiler is a paid feature. Since it uses an LLM for conversion from Helm to helmtk, the usage is billed by number of tokens used. See here for more detail on pricing and purchase.

The decompiler isn't perfect.

It's based on an LLM, and it will occassionally make some decisions that don't make sense, or aren't optimal. Sometimes it will get stuck in a loop trying to fix a test that doesn't make sense.

But, generally it's a useful way to fast forward through an otherwise tedios process of rewriting existing Helm charts, and the test suites it generates are usually pretty thorough and helpful (and most Helm charts are sorely lacking test suites).

Test Generation

The decompiler automatically generates a comprehensive test suite based on your chart's values and logic.

Generated Tests Include:

  • Default values tests - Verify rendering with default values.yaml
  • Conditional logic tests - Test each code path in if statements
  • Loop tests - Verify loops with various array/object inputs
  • Edge case tests - Empty values, null checks, boundary conditions
  • Integration tests - Test interaction between multiple templates

Example Generated Tests

test("renders with default values", t => {
  let resources = t.render()
  t.assert(resources.length > 0)
})

test("sets custom service port", t => {
  t.Values.service.port = 9000
  let service = t.render("service.htkl")
  t.eq(service.spec.ports[0].port, 9000)
})

test("enables ingress when configured", t => {
  t.Values.ingress.enabled = true
  t.Values.ingress.hosts = [{ host: "example.com", paths: ["/"] }]

  let ingress = t.render("ingress.htkl")
  t.assert(ingress !== null)
  t.eq(ingress.spec.rules[0].host, "example.com")
})

test("applies custom labels", t => {
  t.Values.podLabels = { "custom": "label", "env": "prod" }
  let deployment = t.render("deployment.htkl")[0]

  t.eq(deployment.spec.template.metadata.labels.custom, "label")
  t.eq(deployment.spec.template.metadata.labels.env, "prod")
})