From 8ffdc81970f0f4fe5360b077c9eab6633828422e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Torres=20Cogollo?= Date: Sat, 17 Jan 2026 10:43:19 +0100 Subject: [PATCH] feat: New --values-tpl flag with limited template support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pkg/cli/values/options.go | 55 ++++++- pkg/cli/values/options_test.go | 281 +++++++++++++++++++++++++++++++++ pkg/cmd/flags.go | 1 + pkg/engine/engine.go | 70 ++++++-- 4 files changed, 384 insertions(+), 23 deletions(-) diff --git a/pkg/cli/values/options.go b/pkg/cli/values/options.go index cd65fa885..01696902b 100644 --- a/pkg/cli/values/options.go +++ b/pkg/cli/values/options.go @@ -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 +} diff --git a/pkg/cli/values/options_test.go b/pkg/cli/values/options_test.go index fe1afc5d2..6ce718c94 100644 --- a/pkg/cli/values/options_test.go +++ b/pkg/cli/values/options_test.go @@ -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) + } +} diff --git a/pkg/cmd/flags.go b/pkg/cmd/flags.go index 6d9d117f8..8131e38bf 100644 --- a/pkg/cmd/flags.go +++ b/pkg/cmd/flags.go @@ -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)") diff --git a/pkg/engine/engine.go b/pkg/engine/engine.go index f5db7e158..ef3d823c0 100644 --- a/pkg/engine/engine.go +++ b/pkg/engine/engine.go @@ -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 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 "" 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 "" 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(), "", ""), 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 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 "" 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(), "", "") + rendered[filename] = result } return rendered, nil