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