diff --git a/pkg/action/install_test.go b/pkg/action/install_test.go index 7a88d82b5..6c2c91d0a 100644 --- a/pkg/action/install_test.go +++ b/pkg/action/install_test.go @@ -447,7 +447,9 @@ func TestInstallReleaseIncorrectTemplate_DryRun(t *testing.T) { instAction.DryRun = true vals := map[string]interface{}{} _, err := instAction.Run(buildChart(withSampleIncludingIncorrectTemplates()), vals) - expectedErr := "\"hello/templates/incorrect\" at <.Values.bad.doh>: nil pointer evaluating interface {}.doh" + expectedErr := `hello/templates/incorrect:1:10 + executing "hello/templates/incorrect" at <.Values.bad.doh>: + nil pointer evaluating interface {}.doh` if err == nil { t.Fatalf("Install should fail containing error: %s", expectedErr) } diff --git a/pkg/engine/engine.go b/pkg/engine/engine.go index 750eb7f1d..6e47a0e39 100644 --- a/pkg/engine/engine.go +++ b/pkg/engine/engine.go @@ -34,6 +34,18 @@ import ( chartutil "helm.sh/helm/v4/pkg/chart/v2/util" ) +// taken from https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=141 +// > "template: %s: executing %q at <%s>: %s" +var execErrFmt = regexp.MustCompile(`^template: (?P(?U).+): executing (?P(?U).+) at (?P(?U).+): (?P(?U).+)(?P( template:.*)?)$`) + +// taken from https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=138 +// > "template: %s: %s" +var execErrFmtWithoutTemplate = regexp.MustCompile(`^template: (?P(?U).+): (?P.*)(?P( template:.*)?)$`) + +// taken from https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=191 +// > "template: no template %q associated with template %q" +var execErrNoTemplateAssociated = regexp.MustCompile(`^template: no template (?P.*) associated with template (?P(.*)?)$`) + // Engine is an implementation of the Helm rendering implementation for templates. type Engine struct { // If strict is enabled, template rendering will fail if a template references @@ -303,7 +315,7 @@ func (e Engine) render(tpls map[string]renderable) (rendered map[string]string, vals["Template"] = chartutil.Values{"Name": filename, "BasePath": tpls[filename].basePath} var buf strings.Builder if err := t.ExecuteTemplate(&buf, filename, vals); err != nil { - return map[string]string{}, cleanupExecError(filename, err) + return map[string]string{}, reformatExecErrorMsg(filename, err) } // Work around the issue where Go will emit "" even if Options(missing=zero) @@ -329,7 +341,33 @@ func cleanupParseError(filename string, err error) error { return fmt.Errorf("parse error at (%s): %s", string(location), errMsg) } -func cleanupExecError(filename string, err error) error { +type TraceableError struct { + location string + message string + executedFunction string +} + +func (t TraceableError) String() string { + var errorString strings.Builder + if t.location != "" { + fmt.Fprintf(&errorString, "%s\n ", t.location) + } + if t.executedFunction != "" { + fmt.Fprintf(&errorString, "%s\n ", t.executedFunction) + } + if t.message != "" { + fmt.Fprintf(&errorString, "%s\n", t.message) + } + return errorString.String() +} + +// reformatExecErrorMsg takes an error message for template rendering and formats it into a formatted +// multi-line error string +func reformatExecErrorMsg(filename string, err error) error { + // This function matches the error message against regex's for the text/template package. + // If the regex's can parse out details from that error message such as the line number, template it failed on, + // and error description, then it will construct a new error that displays these details in a structured way. + // If there are issues with parsing the error message, the err passed into the function should return instead. if _, isExecError := err.(template.ExecError); !isExecError { return err } @@ -348,8 +386,46 @@ func cleanupExecError(filename string, err error) error { if len(parts) >= 2 { return fmt.Errorf("execution error at (%s): %s", string(location), parts[1]) } + current := err + fileLocations := []TraceableError{} + for current != nil { + var traceable TraceableError + if matches := execErrFmt.FindStringSubmatch(current.Error()); matches != nil { + templateName := matches[execErrFmt.SubexpIndex("templateName")] + functionName := matches[execErrFmt.SubexpIndex("functionName")] + locationName := matches[execErrFmt.SubexpIndex("location")] + errMsg := matches[execErrFmt.SubexpIndex("errMsg")] + traceable = TraceableError{ + location: templateName, + message: errMsg, + executedFunction: "executing " + functionName + " at " + locationName + ":", + } + } else if matches := execErrFmtWithoutTemplate.FindStringSubmatch(current.Error()); matches != nil { + templateName := matches[execErrFmt.SubexpIndex("templateName")] + errMsg := matches[execErrFmt.SubexpIndex("errMsg")] + traceable = TraceableError{ + location: templateName, + message: errMsg, + } + } else if matches := execErrNoTemplateAssociated.FindStringSubmatch(current.Error()); matches != nil { + traceable = TraceableError{ + message: current.Error(), + } + } else { + return err + } + if len(fileLocations) == 0 || fileLocations[len(fileLocations)-1] != traceable { + fileLocations = append(fileLocations, traceable) + } + current = errors.Unwrap(current) + } + + var finalErrorString strings.Builder + for _, fileLocation := range fileLocations { + fmt.Fprintf(&finalErrorString, "%s", fileLocation.String()) + } - return err + return errors.New(strings.TrimSpace(finalErrorString.String())) } func sortTemplates(tpls map[string]renderable) []string { diff --git a/pkg/engine/engine_test.go b/pkg/engine/engine_test.go index 68e0158fa..f4228fbd7 100644 --- a/pkg/engine/engine_test.go +++ b/pkg/engine/engine_test.go @@ -24,6 +24,8 @@ import ( "testing" "text/template" + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -1289,16 +1291,82 @@ func TestRenderTplMissingKeyString(t *testing.T) { t.Errorf("Expected error, got %v", out) return } - switch err.(type) { - case (template.ExecError): - errTxt := fmt.Sprint(err) - if !strings.Contains(errTxt, "noSuchKey") { - t.Errorf("Expected error to contain 'noSuchKey', got %s", errTxt) - } - default: - // Some unexpected error. - t.Fatal(err) + errTxt := fmt.Sprint(err) + if !strings.Contains(errTxt, "noSuchKey") { + t.Errorf("Expected error to contain 'noSuchKey', got %s", errTxt) + } + +} + +func TestNestedHelpersProducesMultilineStacktrace(t *testing.T) { + c := &chart.Chart{ + Metadata: &chart.Metadata{Name: "NestedHelperFunctions"}, + Templates: []*chart.File{ + {Name: "templates/svc.yaml", Data: []byte( + `name: {{ include "nested_helper.name" . }}`, + )}, + {Name: "templates/_helpers_1.tpl", Data: []byte( + `{{- define "nested_helper.name" -}}{{- include "common.names.get_name" . -}}{{- end -}}`, + )}, + {Name: "charts/common/templates/_helpers_2.tpl", Data: []byte( + `{{- define "common.names.get_name" -}}{{- .Values.nonexistant.key | trunc 63 | trimSuffix "-" -}}{{- end -}}`, + )}, + }, + } + + expectedErrorMessage := `NestedHelperFunctions/templates/svc.yaml:1:9 + executing "NestedHelperFunctions/templates/svc.yaml" at : + error calling include: +NestedHelperFunctions/templates/_helpers_1.tpl:1:39 + executing "nested_helper.name" at : + error calling include: +NestedHelperFunctions/charts/common/templates/_helpers_2.tpl:1:49 + executing "common.names.get_name" at <.Values.nonexistant.key>: + nil pointer evaluating interface {}.key` + + v := chartutil.Values{} + + val, _ := chartutil.CoalesceValues(c, v) + vals := map[string]interface{}{ + "Values": val.AsMap(), } + _, err := Render(c, vals) + + assert.NotNil(t, err) + assert.Equal(t, expectedErrorMessage, err.Error()) +} + +func TestMultilineNoTemplateAssociatedError(t *testing.T) { + c := &chart.Chart{ + Metadata: &chart.Metadata{Name: "multiline"}, + Templates: []*chart.File{ + {Name: "templates/svc.yaml", Data: []byte( + `name: {{ include "nested_helper.name" . }}`, + )}, + {Name: "templates/test.yaml", Data: []byte( + `{{ toYaml .Values }}`, + )}, + {Name: "charts/common/templates/_helpers_2.tpl", Data: []byte( + `{{ toYaml .Values }}`, + )}, + }, + } + + expectedErrorMessage := `multiline/templates/svc.yaml:1:9 + executing "multiline/templates/svc.yaml" at : + error calling include: +template: no template "nested_helper.name" associated with template "gotpl"` + + v := chartutil.Values{} + + val, _ := chartutil.CoalesceValues(c, v) + vals := map[string]interface{}{ + "Values": val.AsMap(), + } + _, err := Render(c, vals) + + assert.NotNil(t, err) + assert.Equal(t, expectedErrorMessage, err.Error()) } func TestRenderCustomTemplateFuncs(t *testing.T) {