pull/31740/merge
Álvaro Torres Cogollo 4 days ago committed by GitHub
commit 48395b06a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

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