feat: New --values-tpl flag with limited template support

Use --values-tpl to dynamically generate values based on previously
merged configuration. Templates have access to .Values with basic
template functions.

This is especially useful when you don't have control over upstream
chart templates and want to avoid forking just to add tpl support.

Templates are rendered after all other value sources (--values, --set)
and have access to the merged .Values context:

```bash
helm install myapp ./chart --values-tpl dynamic.yaml.tpl

helm install myapp ./chart \
  --values base.yaml \
  --set environment=production \
  --values-tpl computed.yaml.tpl
```

```yaml
region: us-east-1
environment: dev

replicas: {{ if eq .Values.environment "production" }}5{{ else }}1{{ end }}
resourceTier: {{ .Values.environment | upper }}
endpoint: "https://{{ .Values.region }}.example.com"
```

1. Chart defaults (`values.yaml`)
2. All `--values` files
3. All `--set*` flags
4. All `--values-tpl` files (rendered with access to steps 1-3)

**Problem:** Each cluster is in a different AWS region/account, and you need
different ECR registries. You want to:
- Avoid committing per-cluster files (values-prod-us.yaml, values-prod-eu.yaml, etc.)
- Not edit upstream chart templates to add tpl support
- Keep ArgoCD unaware of chart-specific paths (image.registry, etc.)

**Solution:** Single template file in git, cluster-specific values via ArgoCD:

```yaml
image:
  registry: "{{ .Values.accountId }}.dkr.ecr.{{ .Values.region }}.amazonaws.com"
  repository: myapp
  tag: "1.0.0"

service:
  extraEnvironment:  # Arbitrary path in values
    CLUSTER_NAME: "{{ .Values.clusterName }}"
```

```yaml
spec:
  helm:
    parameters:
    - name: clusterName
      value: prod-us-east
    - name: region
      value: us-east-1
    - name: accountId
      value: "123456789"
    valueTplFiles:
    - values-custom.yaml.tpl
```

```yaml
spec:
  helm:
    parameters:
    - name: clusterName
      value: prod-eu-west
    - name: region
      value: eu-west-1
    - name: accountId
      value: "987654321"
    valueTplFiles:
    - values-custom.yaml.tpl
```

- Access to `.Values` from previous merge steps
- Sprig functions: `default`, `upper`, `lower`, `trim`, etc.
- Format functions: `toYaml`, `toJson`, `toToml`, `fromYaml`, `fromJson`, `fromToml`
- Conditionals: `if`/`else`, `range`
- Remote templates: `http://`, `https://`, etc.

- `include` (requires chart template context)
- `tpl` (requires chart template context)
- `lookup` (requires Kubernetes client)

Signed-off-by: Álvaro Torres Cogollo <atorrescogollo@gmail.com>
pull/31740/head
Álvaro Torres Cogollo 3 weeks ago
parent edbb47474c
commit 8ffdc81970

@ -26,18 +26,20 @@ import (
"strings"
"helm.sh/helm/v4/pkg/chart/v2/loader"
"helm.sh/helm/v4/pkg/engine"
"helm.sh/helm/v4/pkg/getter"
"helm.sh/helm/v4/pkg/strvals"
)
// Options captures the different ways to specify values
type Options struct {
ValueFiles []string // -f/--values
StringValues []string // --set-string
Values []string // --set
FileValues []string // --set-file
JSONValues []string // --set-json
LiteralValues []string // --set-literal
ValueFiles []string // -f/--values
ValueTemplates []string // --values-tpl
StringValues []string // --set-string
Values []string // --set
FileValues []string // --set-file
JSONValues []string // --set-json
LiteralValues []string // --set-literal
}
// MergeValues merges values from files specified via -f/--values and directly
@ -112,6 +114,19 @@ func (opts *Options) MergeValues(p getter.Providers) (map[string]interface{}, er
}
}
// User specified a values template via --values-tpl
for _, filePath := range opts.ValueTemplates {
raw, err := readFile(filePath, p)
if err != nil {
return nil, err
}
rendered, err := LoadValuesTpl(filePath, string(raw), base)
if err != nil {
return nil, fmt.Errorf("failed to render values template %s: %w", filePath, err)
}
base = loader.MergeMaps(base, rendered)
}
return base, nil
}
@ -136,3 +151,31 @@ func readFile(filePath string, p getter.Providers) ([]byte, error) {
}
return data.Bytes(), nil
}
// LoadValuesTpl renders a values file as a Go template with .Values context
func LoadValuesTpl(
templatePath string,
templateData string,
currentValues map[string]interface{},
) (map[string]interface{}, error) {
// Create template context with .Values
context := map[string]interface{}{
"Values": currentValues,
}
// Render using the engine
eng := engine.Engine{}
rendered, err := eng.RenderString(templatePath, templateData, context)
if err != nil {
return nil, fmt.Errorf("failed to render template %s: %w", templatePath, err)
}
// Parse the rendered YAML output
buf := bytes.NewBufferString(rendered)
result, err := loader.LoadValues(buf)
if err != nil {
return nil, fmt.Errorf("failed to parse rendered template %s as YAML: %w", templatePath, err)
}
return result, nil
}

@ -387,3 +387,284 @@ func TestMergeValuesCLI(t *testing.T) {
})
}
}
func TestLoadValuesTpl(t *testing.T) {
tests := []struct {
name string
templateData string
currentValues map[string]interface{}
expected map[string]interface{}
wantErr bool
errContains string
}{
{
name: "basic template with .Values access",
templateData: `
environment: {{ .Values.env }}
replicas: {{ .Values.replicas }}
`,
currentValues: map[string]interface{}{
"env": "production",
"replicas": float64(5),
},
expected: map[string]interface{}{
"environment": "production",
"replicas": float64(5),
},
wantErr: false,
},
{
name: "template with conditional logic",
templateData: `
{{- if eq .Values.env "production" }}
replicas: 5
resources:
limits:
cpu: "2000m"
{{- else }}
replicas: 1
resources:
limits:
cpu: "500m"
{{- end }}
`,
currentValues: map[string]interface{}{
"env": "production",
},
expected: map[string]interface{}{
"replicas": float64(5),
"resources": map[string]interface{}{
"limits": map[string]interface{}{
"cpu": "2000m",
},
},
},
wantErr: false,
},
{
name: "template with Sprig default function",
templateData: `
environment: {{ .Values.env | default "dev" }}
debug: {{ .Values.debug | default false }}
`,
currentValues: map[string]interface{}{},
expected: map[string]interface{}{
"environment": "dev",
"debug": false,
},
wantErr: false,
},
{
name: "template with toYaml function",
templateData: `
config: |
{{ .Values.config | toYaml | indent 2 }}
`,
currentValues: map[string]interface{}{
"config": map[string]interface{}{
"key1": "value1",
"key2": "value2",
},
},
expected: map[string]interface{}{
"config": "key1: value1\nkey2: value2\n",
},
wantErr: false,
},
{
name: "template with toJson function",
templateData: `
jsonConfig: '{{ .Values.data | toJson }}'
`,
currentValues: map[string]interface{}{
"data": map[string]interface{}{
"foo": "bar",
},
},
expected: map[string]interface{}{
"jsonConfig": `{"foo":"bar"}`,
},
wantErr: false,
},
{
name: "template with fromYaml function",
templateData: `
{{- $parsed := .Values.yamlString | fromYaml }}
key: {{ $parsed.foo }}
`,
currentValues: map[string]interface{}{
"yamlString": "foo: bar",
},
expected: map[string]interface{}{
"key": "bar",
},
wantErr: false,
},
{
name: "empty template",
templateData: `
# Just a comment
`,
currentValues: map[string]interface{}{
"env": "prod",
},
expected: map[string]interface{}{},
wantErr: false,
},
{
name: "template with undefined value (using default)",
templateData: `
value: {{ .Values.undefined | default "fallback" }}
`,
currentValues: map[string]interface{}{},
expected: map[string]interface{}{
"value": "fallback",
},
wantErr: false,
},
{
name: "template syntax error",
templateData: `
value: {{ .Values.env
`,
currentValues: map[string]interface{}{
"env": "prod",
},
wantErr: true,
errContains: "unclosed action",
},
{
name: "template execution error - invalid function",
templateData: `
value: {{ .Values.env | nonExistentFunction }}
`,
currentValues: map[string]interface{}{
"env": "prod",
},
wantErr: true,
errContains: "not defined",
},
{
name: "template outputs invalid YAML",
templateData: `
invalid: {{ "{this is not valid yaml: [}" }}
`,
currentValues: map[string]interface{}{},
wantErr: true,
errContains: "failed to parse rendered template",
},
{
name: "template with nested values access",
templateData: `
database:
host: {{ .Values.db.host }}
port: {{ .Values.db.port }}
`,
currentValues: map[string]interface{}{
"db": map[string]interface{}{
"host": "localhost",
"port": float64(5432),
},
},
expected: map[string]interface{}{
"database": map[string]interface{}{
"host": "localhost",
"port": float64(5432),
},
},
wantErr: false,
},
{
name: "template with list iteration",
templateData: `
services:
{{- range .Values.services }}
- name: {{ .name }}
port: {{ .port }}
{{- end }}
`,
currentValues: map[string]interface{}{
"services": []interface{}{
map[string]interface{}{"name": "web", "port": float64(80)},
map[string]interface{}{"name": "api", "port": float64(8080)},
},
},
expected: map[string]interface{}{
"services": []interface{}{
map[string]interface{}{"name": "web", "port": float64(80)},
map[string]interface{}{"name": "api", "port": float64(8080)},
},
},
wantErr: false,
},
{
name: "template with upper and lower functions",
templateData: `
upper: {{ .Values.text | upper }}
lower: {{ .Values.text | lower }}
`,
currentValues: map[string]interface{}{
"text": "Hello World",
},
expected: map[string]interface{}{
"upper": "HELLO WORLD",
"lower": "hello world",
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Render the template
got, err := LoadValuesTpl(t.Name(), tt.templateData, tt.currentValues)
// Check error expectations
if (err != nil) != tt.wantErr {
t.Errorf("LoadValuesTpl() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("LoadValuesTpl() error = %v, want error containing %q", err, tt.errContains)
}
return
}
// Compare results
if !reflect.DeepEqual(got, tt.expected) {
t.Errorf("LoadValuesTpl() got = %#v, want %#v", got, tt.expected)
}
})
}
}
func TestLoadValuesTpl_InvalidTemplate(t *testing.T) {
// Test with invalid template syntax
_, err := LoadValuesTpl(t.Name(), "{{ .Values.foo", map[string]interface{}{})
if err == nil {
t.Error("LoadValuesTpl() expected error for invalid template, got nil")
}
if !strings.Contains(err.Error(), "failed to render template") {
t.Errorf("LoadValuesTpl() error = %v, want error containing 'failed to render template'", err)
}
}
func TestLoadValuesTpl_EmptyCurrentValues(t *testing.T) {
templateData := `
fallback: {{ .Values.missing | default "defaultValue" }}
`
got, err := LoadValuesTpl(t.Name(), templateData, map[string]interface{}{})
if err != nil {
t.Fatalf("LoadValuesTpl() unexpected error: %v", err)
}
expected := map[string]interface{}{
"fallback": "defaultValue",
}
if !reflect.DeepEqual(got, expected) {
t.Errorf("LoadValuesTpl() = %v, want %v", got, expected)
}
}

@ -48,6 +48,7 @@ const (
func addValueOptionsFlags(f *pflag.FlagSet, v *values.Options) {
f.StringSliceVarP(&v.ValueFiles, "values", "f", []string{}, "specify values in a YAML file or a URL (can specify multiple)")
f.StringSliceVar(&v.ValueTemplates, "values-tpl", []string{}, "specify values in a YAML template file or a URL that will be rendered before merging (can specify multiple)")
f.StringArrayVar(&v.Values, "set", []string{}, "set values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2)")
f.StringArrayVar(&v.StringValues, "set-string", []string{}, "set STRING values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2)")
f.StringArrayVar(&v.FileValues, "set-file", []string{}, "set values from respective files specified via the command line (can specify multiple or separate values with commas: key1=path1,key2=path2)")

@ -107,6 +107,22 @@ func RenderWithClientProvider(chrt ci.Charter, values common.Values, clientProvi
}.Render(chrt, values)
}
// RenderString renders a single template string with the given values.
// This is useful for rendering standalone templates without a full chart context.
// The template will have access to all standard Helm template functions.
//
// Context-dependent functions (include, tpl, lookup, required) are available but
// will return placeholder values or errors if called, since there is no chart context.
func (e Engine) RenderString(templateName string, templateContent string, values interface{}) (string, error) {
t := e.initTemplate()
if _, err := t.New(templateName).Parse(templateContent); err != nil {
return "", err
}
return e.executeTemplate(t, templateName, values)
}
// renderable is an object that can be rendered.
type renderable struct {
// tpl is the current template.
@ -194,6 +210,38 @@ func tplFun(parent *template.Template, includedNames map[string]int, strict bool
}
}
// initTemplate creates and initializes a new template with standard options and function map.
// This ensures consistent behavior across all template rendering paths.
func (e Engine) initTemplate() *template.Template {
t := template.New("gotpl")
// Set missing key behavior based on Strict mode
if e.Strict {
t.Option("missingkey=error")
} else {
// Not that zero will attempt to add default values for types it knows,
// but will still emit <no value> for others. We mitigate that later.
t.Option("missingkey=zero")
}
e.initFunMap(t)
return t
}
// executeTemplate executes a template and returns the rendered output.
// It handles the workaround for Go emitting "<no value>" when missingkey=zero is set.
func (e Engine) executeTemplate(t *template.Template, name string, data interface{}) (string, error) {
var buf strings.Builder
if err := t.ExecuteTemplate(&buf, name, data); err != nil {
return "", err
}
// Work around the issue where Go will emit "<no value>" even if Options(missing=zero)
// is set. Since missing=error will never get here, we do not need to handle
// the Strict case.
return strings.ReplaceAll(buf.String(), "<no value>", ""), nil
}
// initFunMap creates the Engine's FuncMap and adds context-specific functions.
func (e Engine) initFunMap(t *template.Template) {
funcMap := funcMap()
@ -269,16 +317,7 @@ func (e Engine) render(tpls map[string]renderable) (rendered map[string]string,
err = fmt.Errorf("rendering template failed: %v", r)
}
}()
t := template.New("gotpl")
if e.Strict {
t.Option("missingkey=error")
} else {
// Not that zero will attempt to add default values for types it knows,
// but will still emit <no value> for others. We mitigate that later.
t.Option("missingkey=zero")
}
e.initFunMap(t)
t := e.initTemplate()
// We want to parse the templates in a predictable order. The order favors
// higher-level (in file system) templates over deeply nested templates.
@ -301,15 +340,12 @@ func (e Engine) render(tpls map[string]renderable) (rendered map[string]string,
// At render time, add information about the template that is being rendered.
vals := tpls[filename].vals
vals["Template"] = common.Values{"Name": filename, "BasePath": tpls[filename].basePath}
var buf strings.Builder
if err := t.ExecuteTemplate(&buf, filename, vals); err != nil {
result, err := e.executeTemplate(t, filename, vals)
if err != nil {
return map[string]string{}, reformatExecErrorMsg(filename, err)
}
// Work around the issue where Go will emit "<no value>" even if Options(missing=zero)
// is set. Since missing=error will never get here, we do not need to handle
// the Strict case.
rendered[filename] = strings.ReplaceAll(buf.String(), "<no value>", "")
rendered[filename] = result
}
return rendered, nil

Loading…
Cancel
Save