Repository: GoogleContainerTools/kpt
Pull request #3679 (2022-11-30 23:23:45)

Don't use templating to build error messages
It's pretty hard to understand, and we don't need the complexity.



Patch:
diff --git a/internal/errors/resolver/git.go b/internal/errors/resolver/git.go
index 877ceca6..11e8903f 100644
--- a/internal/errors/resolver/git.go
+++ b/internal/errors/resolver/git.go
@@ -27,50 +27,6 @@ func init() {
 	AddErrorResolver(&gitExecErrorResolver{})
 }
 
-const (
-	genericGitExecError = `
-Error: Failed to execute git command {{ printf "%q " .gitcmd }}
-{{- if gt (len .repo) 0 -}}
-against repo {{ printf "%q " .repo }}
-{{- end }}
-{{- if gt (len .ref) 0 -}}
-for reference {{ printf "%q " .ref }}
-{{- end }}
-
-{{- template "ExecOutputDetails" . }}
-`
-
-	unknownRefGitExecError = `
-Error: Unknown ref {{ printf "%q" .ref }}. Please verify that the reference exists in upstream repo {{ printf "%q" .repo }}.
-
-{{- template "ExecOutputDetails" . }}
-`
-
-	noGitBinaryError = `
-Error: No git executable found. kpt requires git to be installed and available in the path.
-
-{{- template "ExecOutputDetails" . }}
-`
-
-	httpsAuthRequired = `
-Error: Repository {{ printf "%q" .repo }} requires authentication. kpt does not support this for the 'https' protocol. Please use the 'git' protocol instead.
-
-{{- template "ExecOutputDetails" . }}
-`
-
-	repositoryUnavailable = `
-Error: Unable to access repository {{ printf "%q" .repo }}.
-
-{{- template "ExecOutputDetails" . }}
-`
-
-	repositoryNotFound = `
-Error: Repository {{ printf "%q" .repo }} not found.
-
-{{- template "ExecOutputDetails" . }}
-`
-)
-
 // gitExecErrorResolver is an implementation of the ErrorResolver interface
 // that can produce error messages for errors of the gitutil.GitExecError type.
 type gitExecErrorResolver struct{}
@@ -82,29 +38,55 @@ func (*gitExecErrorResolver) Resolve(err error) (ResolvedResult, bool) {
 	}
 	fullCommand := fmt.Sprintf("git %s %s", gitExecErr.Command,
 		strings.Join(gitExecErr.Args, " "))
-	tmplArgs := map[string]interface{}{
-		"gitcmd": fullCommand,
-		"repo":   gitExecErr.Repo,
-		"ref":    gitExecErr.Ref,
-		"stdout": gitExecErr.StdOut,
-		"stderr": gitExecErr.StdErr,
-	}
+
 	var msg string
 	switch gitExecErr.Type {
 	case gitutil.UnknownReference:
-		msg = ExecuteTemplate(unknownRefGitExecError, tmplArgs)
+		msg = fmt.Sprintf("Error: Unknown ref %q. Please verify that the reference exists in upstream repo %q.", gitExecErr.Ref, gitExecErr.Repo)
+		msg = msg + "\n" + BuildOutputDetails(gitExecErr.StdOut, gitExecErr.StdErr)
+
 	case gitutil.GitExecutableNotFound:
-		msg = ExecuteTemplate(noGitBinaryError, tmplArgs)
+		msg = "Error: No git executable found. kpt requires git to be installed and available in the path."
+		msg = msg + "\n" + BuildOutputDetails(gitExecErr.StdOut, gitExecErr.StdErr)
+
 	case gitutil.HTTPSAuthRequired:
-		msg = ExecuteTemplate(httpsAuthRequired, tmplArgs)
+		msg = fmt.Sprintf("Error: Repository %q requires authentication.", gitExecErr.Repo)
+		msg += " kpt does not support this for the 'https' protocol."
+		msg += " Please use the 'git' protocol instead."
+		msg = msg + "\n" + BuildOutputDetails(gitExecErr.StdOut, gitExecErr.StdErr)
+
 	case gitutil.RepositoryUnavailable:
-		msg = ExecuteTemplate(repositoryUnavailable, tmplArgs)
+		msg = fmt.Sprintf("Error: Unable to access repository %q.", gitExecErr.Repo)
+		msg = msg + "\n" + BuildOutputDetails(gitExecErr.StdOut, gitExecErr.StdErr)
+
 	case gitutil.RepositoryNotFound:
-		msg = ExecuteTemplate(repositoryNotFound, tmplArgs)
+		msg = fmt.Sprintf("Error: Repository %q not found.", gitExecErr.Repo)
+		msg = msg + "\n" + BuildOutputDetails(gitExecErr.StdOut, gitExecErr.StdErr)
 	default:
-		msg = ExecuteTemplate(genericGitExecError, tmplArgs)
+		msg = fmt.Sprintf("Error: Failed to execute git command %q", fullCommand)
+		if gitExecErr.Repo != "" {
+			msg += fmt.Sprintf(" against repo %q", gitExecErr.Repo)
+		}
+		if gitExecErr.Ref != "" {
+			msg += fmt.Sprintf(" for reference %q", gitExecErr.Ref)
+		}
+		msg = msg + "\n" + BuildOutputDetails(gitExecErr.StdOut, gitExecErr.StdErr)
 	}
 	return ResolvedResult{
 		Message: msg,
 	}, true
 }
+
+func BuildOutputDetails(stdout string, stderr string) string {
+	var sb strings.Builder
+	if len(stdout) > 0 || len(stderr) > 0 {
+		sb.WriteString("\nDetails:\n")
+	}
+	if len(stdout) > 0 {
+		sb.WriteString(stdout)
+	}
+	if len(stderr) > 0 {
+		sb.WriteString(stderr)
+	}
+	return sb.String()
+}
diff --git a/internal/errors/resolver/live.go b/internal/errors/resolver/live.go
index ce70321c..a715e5f2 100644
--- a/internal/errors/resolver/live.go
+++ b/internal/errors/resolver/live.go
@@ -15,6 +15,8 @@
 package resolver
 
 import (
+	"fmt"
+
 	initialization "github.com/GoogleContainerTools/kpt/commands/live/init"
 	"github.com/GoogleContainerTools/kpt/internal/cmdutil"
 	"github.com/GoogleContainerTools/kpt/internal/errors"
@@ -45,14 +47,6 @@ Error: Package has multiple inventory object templates.
 The package should have one and only one inventory object template.
 `
 
-	resourceGroupCRDInstallErrorMsg = `
-Error: Unable to install the ResourceGroup CRD.
-
-{{- if gt (len .cause) 0 }}
-{{ printf "\nDetails:" }}
-{{ printf "%s" .cause }}
-{{- end }}
-`
 	//nolint:lll
 	noResourceGroupCRDMsg = `
 Error: The ResourceGroup CRD was not found in the cluster. Please install it either by using the '--install-resource-group' flag or the 'kpt live install-resource-group' command.
@@ -75,25 +69,6 @@ Error: Inventory information has already been added to the package Kptfile objec
 
 	multipleResourceGroupsMsg = `
 Error: Multiple ResourceGroup objects found. Please make sure at most one ResourceGroup object exists within the package.
-`
-
-	//nolint:lll
-	inventoryInfoValidationMsg = `
-Error: The inventory information is not valid. Please update the information in the ResourceGroup file or provide information with the command line flags. To generate the inventory information the first time, use the 'kpt live init' command.
-
-Details:
-{{- range .err.Violations}}
-{{printf "%s" .Reason }}
-{{- end}}
-`
-
-	unknownTypesMsg = `
-Error: {{ printf "%d" (len .err.GroupVersionKinds) }} resource types could not be found in the cluster or as CRDs among the applied resources.
-
-Resource types:
-{{- range .err.GroupVersionKinds}}
-{{ printf "%s" .String }}
-{{- end}}
 `
 )
 
@@ -104,92 +79,79 @@ type liveErrorResolver struct{}
 func (*liveErrorResolver) Resolve(err error) (ResolvedResult, bool) {
 	var noInventoryObjError *inventory.NoInventoryObjError
 	if errors.As(err, &noInventoryObjError) {
-		return ResolvedResult{
-			Message: ExecuteTemplate(noInventoryObjErrorMsg, map[string]interface{}{
-				"err": *noInventoryObjError,
-			}),
-		}, true
+		msg := noInventoryObjErrorMsg
+		return ResolvedResult{Message: msg}, true
 	}
 
 	var multipleInventoryObjError *inventory.MultipleInventoryObjError
 	if errors.As(err, &multipleInventoryObjError) {
-		return ResolvedResult{
-			Message: ExecuteTemplate(multipleInventoryObjErrorMsg, map[string]interface{}{
-				"err": *multipleInventoryObjError,
-			}),
-		}, true
+		msg := multipleInventoryObjErrorMsg
+		return ResolvedResult{Message: msg}, true
 	}
 
 	var resourceGroupCRDInstallError *cmdutil.ResourceGroupCRDInstallError
 	if errors.As(err, &resourceGroupCRDInstallError) {
-		return ResolvedResult{
-			Message: ExecuteTemplate(resourceGroupCRDInstallErrorMsg, map[string]interface{}{
-				"cause": resourceGroupCRDInstallError.Err.Error(),
-			}),
-		}, true
+		msg := "Error: Unable to install the ResourceGroup CRD."
+
+		cause := resourceGroupCRDInstallError.Err
+		msg += fmt.Sprintf("\nDetails: %v", cause)
+
+		return ResolvedResult{Message: msg}, true
 	}
 
 	var noResourceGroupCRDError *cmdutil.NoResourceGroupCRDError
 	if errors.As(err, &noResourceGroupCRDError) {
-		return ResolvedResult{
-			Message: ExecuteTemplate(noResourceGroupCRDMsg, map[string]interface{}{
-				"err": *noResourceGroupCRDError,
-			}),
-		}, true
+		msg := noResourceGroupCRDMsg
+		return ResolvedResult{Message: msg}, true
 	}
 
 	var invExistsError *initialization.InvExistsError
 	if errors.As(err, &invExistsError) {
-		return ResolvedResult{
-			Message: ExecuteTemplate(invInfoAlreadyExistsMsg, map[string]interface{}{
-				"err": *invExistsError,
-			}),
-		}, true
+		msg := invInfoAlreadyExistsMsg
+		return ResolvedResult{Message: msg}, true
 	}
 
 	var invInfoInRGAlreadyExistsError *initialization.InvInRGExistsError
 	if errors.As(err, &invInfoInRGAlreadyExistsError) {
-		return ResolvedResult{
-			Message: ExecuteTemplate(invInfoInRGAlreadyExistsMsg, map[string]interface{}{
-				"err": *invInfoInRGAlreadyExistsError,
-			}),
-		}, true
+		msg := invInfoInRGAlreadyExistsMsg
+		return ResolvedResult{Message: msg}, true
 	}
 
 	var invInKfExistsError *initialization.InvInKfExistsError
 	if errors.As(err, &invInKfExistsError) {
-		return ResolvedResult{
-			Message: ExecuteTemplate(invInfoInKfAlreadyExistsMsg, map[string]interface{}{
-				"err": *invInKfExistsError,
-			}),
-		}, true
+		msg := invInfoInKfAlreadyExistsMsg
+		return ResolvedResult{Message: msg}, true
 	}
 
 	var multipleResourceGroupsError *pkg.MultipleResourceGroupsError
 	if errors.As(err, &multipleResourceGroupsError) {
-		return ResolvedResult{
-			Message: ExecuteTemplate(multipleResourceGroupsMsg, map[string]interface{}{
-				"err": *multipleResourceGroupsError,
-			}),
-		}, true
+		msg := multipleResourceGroupsMsg
+		return ResolvedResult{Message: msg}, true
 	}
 
 	var inventoryInfoValidationError *live.InventoryInfoValidationError
 	if errors.As(err, &inventoryInfoValidationError) {
-		return ResolvedResult{
-			Message: ExecuteTemplate(inventoryInfoValidationMsg, map[string]interface{}{
-				"err": *inventoryInfoValidationError,
-			}),
-		}, true
+		msg := "Error: The inventory information is not valid."
+		msg += " Please update the information in the ResourceGroup file or provide information with the command line flags."
+		msg += " To generate the inventory information the first time, use the 'kpt live init' command."
+
+		msg += "\nDetails:\n"
+		for _, v := range inventoryInfoValidationError.Violations {
+			msg += fmt.Sprintf("%s\n", v.Reason)
+		}
+
+		return ResolvedResult{Message: msg}, true
 	}
 
 	var unknownTypesError *manifestreader.UnknownTypesError
 	if errors.As(err, &unknownTypesError) {
-		return ResolvedResult{
-			Message: ExecuteTemplate(unknownTypesMsg, map[string]interface{}{
-				"err": *unknownTypesError,
-			}),
-		}, true
+		msg := fmt.Sprintf("Error: %d resource types could not be found in the cluster or as CRDs among the applied resources.", len(unknownTypesError.GroupVersionKinds))
+		msg += "\n\nResource types:\n"
+		for _, gvk := range unknownTypesError.GroupVersionKinds {
+			msg += fmt.Sprintf("%s\n", gvk)
+		}
+
+		return ResolvedResult{Message: msg}, true
 	}
 
 	var resultError *common.ResultError
diff --git a/internal/errors/resolver/pkg.go b/internal/errors/resolver/pkg.go
index 63e17a85..71592aa2 100644
--- a/internal/errors/resolver/pkg.go
+++ b/internal/errors/resolver/pkg.go
@@ -15,6 +15,7 @@
 package resolver
 
 import (
+	"fmt"
 	"os"
 
 	"github.com/GoogleContainerTools/kpt/internal/errors"
@@ -27,33 +28,6 @@ func init() {
 	AddErrorResolver(&pkgErrorResolver{})
 }
 
-const (
-	noKptfileMsg = `
-Error: No Kptfile found at {{ printf "%q" .path }}.
-`
-
-	//nolint:lll
-	deprecatedv1Alpha1KptfileMsg = `
-Error: Kptfile at {{ printf "%q" .path }} has an old version ({{ printf "%q" .version }}) of the Kptfile schema.
-Please update the package to the latest format by following https://kpt.dev/installation/migration.
-`
-
-	deprecatedv1Alpha2KptfileMsg = `
-Error: Kptfile at {{ printf "%q" .path }} has an old version ({{ printf "%q" .version }}) of the Kptfile schema.
-Please run "kpt fn eval <PKG_PATH> -i gcr.io/kpt-fn/fix:v0.2 --include-meta-resources" to upgrade the package and retry.
-`
-
-	unknownKptfileResourceMsg = `
-Error: Kptfile at {{ printf "%q" .path }} has an unknown resource type ({{ printf "%q" .gvk.String }}).
-`
-
-	kptfileReadErrMsg = `
-Error: Kptfile at {{ printf "%q" .path }} can't be read.
-
-{{- template "NestedErrDetails" . }}
-`
-)
-
 // pkgErrorResolver is an implementation of the ErrorResolver interface
 // that can produce error messages for errors of the pkg.KptfileError type.
 type pkgErrorResolver struct{}
@@ -62,22 +36,15 @@ func (*pkgErrorResolver) Resolve(err error) (ResolvedResult, bool) {
 	var kptfileError *pkg.KptfileError
 	if errors.As(err, &kptfileError) {
 		path := kptfileError.Path
-		tmplArgs := map[string]interface{}{
-			"path": path,
-			"err":  kptfileError,
-		}
 
-		return resolveNestedErr(kptfileError, tmplArgs)
+		return resolveNestedErr(kptfileError, path.String())
 	}
 
 	var remoteKptfileError *pkg.RemoteKptfileError
 	if errors.As(err, &remoteKptfileError) {
 		path := remoteKptfileError.RepoSpec.RepoRef()
-		tmplArgs := map[string]interface{}{
-			"path": path,
-			"err":  kptfileError,
-		}
-		return resolveNestedErr(remoteKptfileError, tmplArgs)
+
+		return resolveNestedErr(remoteKptfileError, path)
 	}
 
 	var validateError *kptfile.ValidateError
@@ -90,42 +57,58 @@ func (*pkgErrorResolver) Resolve(err error) (ResolvedResult, bool) {
 	return ResolvedResult{}, false
 }
 
-func resolveNestedErr(err error, tmplArgs map[string]interface{}) (ResolvedResult, bool) {
+func resolveNestedErr(err error, path string) (ResolvedResult, bool) {
 	if errors.Is(err, os.ErrNotExist) {
+		msg := fmt.Sprintf("Error: No Kptfile found at %q.", path)
+
 		return ResolvedResult{
-			Message: ExecuteTemplate(noKptfileMsg, tmplArgs),
+			Message: msg,
 		}, true
 	}
 
 	var deprecatedv1alpha1KptfileError *pkg.DeprecatedKptfileError
 	if errors.As(err, &deprecatedv1alpha1KptfileError) &&
 		deprecatedv1alpha1KptfileError.Version == "v1alpha1" {
-		tmplArgs["version"] = deprecatedv1alpha1KptfileError.Version
-		errMsg := deprecatedv1Alpha1KptfileMsg
+		msg := fmt.Sprintf("Error: Kptfile at %q has an old version (%q) of the Kptfile schema.\n", path, deprecatedv1alpha1KptfileError.Version)
+		msg += "Please update the package to the latest format by following https://kpt.dev/installation/migration."
+
 		return ResolvedResult{
-			Message: ExecuteTemplate(errMsg, tmplArgs),
+			Message: msg,
 		}, true
 	}
 
 	var deprecatedv1alpha2KptfileError *pkg.DeprecatedKptfileError
 	if errors.As(err, &deprecatedv1alpha2KptfileError) &&
-		deprecatedv1alpha1KptfileError.Version == "v1alpha2" {
-		tmplArgs["version"] = deprecatedv1alpha2KptfileError.Version
-		errMsg := deprecatedv1Alpha2KptfileMsg
+		deprecatedv1alpha2KptfileError.Version == "v1alpha2" {
+		msg := fmt.Sprintf("Error: Kptfile at %q has an old version (%q) of the Kptfile schema.\n", path, deprecatedv1alpha2KptfileError.Version)
+		msg += "Please run \"kpt fn eval <PKG_PATH> -i gcr.io/kpt-fn/fix:v0.2 --include-meta-resources\" to upgrade the package and retry."
+
 		return ResolvedResult{
-			Message: ExecuteTemplate(errMsg, tmplArgs),
+			Message: msg,
 		}, true
 	}
 
 	var unknownKptfileResourceError *pkg.UnknownKptfileResourceError
 	if errors.As(err, &unknownKptfileResourceError) {
-		tmplArgs["gvk"] = unknownKptfileResourceError.GVK
+		msg := fmt.Sprintf("Error: Kptfile at %q has an unknown resource type (%q).", path, unknownKptfileResourceError.GVK.String())
 		return ResolvedResult{
-			Message: ExecuteTemplate(unknownKptfileResourceMsg, tmplArgs),
+			Message: msg,
 		}, true
 	}
 
+	msg := fmt.Sprintf("Error: Kptfile at %q can't be read.", path)
+	if err != nil {
+		var kptFileError *pkg.KptfileError
+		if errors.As(err, &kptFileError) {
+			if kptFileError.Err != nil {
+				msg += fmt.Sprintf("\n\nDetails:\n%v", kptFileError.Err)
+			}
+		} else {
+			msg += fmt.Sprintf("\n\nDetails:\n%v", err)
+		}
+	}
+
 	return ResolvedResult{
-		Message: ExecuteTemplate(kptfileReadErrMsg, tmplArgs),
+		Message: msg,
 	}, true
 }
diff --git a/internal/errors/resolver/template.go b/internal/errors/resolver/template.go
deleted file mode 100644
index 7ad186e2..00000000
--- a/internal/errors/resolver/template.go
+++ /dev/null
@@ -1,79 +0,0 @@
-// Copyright 2021 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package resolver
-
-import (
-	"bytes"
-	"fmt"
-	"strings"
-	"text/template"
-)
-
-var baseTemplate = func() *template.Template {
-	tmpl := template.New("base")
-	tmpl = template.Must(tmpl.Parse(detailsHelperTemplate))
-	tmpl = template.Must(tmpl.Parse(nestedErrTemplate))
-	return tmpl
-}()
-
-var (
-	// detailsHelperTemplate is a helper subtemplate that is available to
-	// the top-level templates. It is useful when including information from
-	// execing other commands in the error message.
-	detailsHelperTemplate = `
-{{- define "ExecOutputDetails" }}
-{{- if or (gt (len .stdout) 0) (gt (len .stderr) 0)}}
-{{ printf "\nDetails:" }}
-{{- end }}
-
-{{- if gt (len .stdout) 0 }}
-{{ printf "%s" .stdout }}
-{{- end }}
-
-{{- if gt (len .stderr) 0 }}
-{{ printf "%s" .stderr }}
-{{- end }}
-{{ end }}
-`
-
-	// nestedErrTemplate is a helper subtemplate for printing details from
-	// a nested error.
-	nestedErrTemplate = `
-{{- define "NestedErrDetails" }}
-{{- if .err  }}
-{{- if .err.Err }}
-{{- if gt (len .err.Err.Error) 0 }}
-{{ printf "\nDetails:" }}
-{{ printf "%s" .err.Err.Error }}
-{{- end }}
-{{- end }}
-{{- end }}
-{{ end }}
-`
-)
-
-// ExecuteTemplate takes the provided template string and data, and renders
-// the template. If something goes wrong, it panics.
-func ExecuteTemplate(text string, data interface{}) string {
-	tmpl := template.Must(baseTemplate.Clone())
-	template.Must(tmpl.Parse(text))
-
-	var b bytes.Buffer
-	execErr := tmpl.Execute(&b, data)
-	if execErr != nil {
-		panic(fmt.Errorf("error executing template: %w", execErr))
-	}
-	return strings.TrimSpace(b.String())
-}
diff --git a/internal/errors/resolver/update.go b/internal/errors/resolver/update.go
index f5f17374..6b43e3ec 100644
--- a/internal/errors/resolver/update.go
+++ b/internal/errors/resolver/update.go
@@ -15,6 +15,8 @@
 package resolver
 
 import (
+	"fmt"
+
 	"github.com/GoogleContainerTools/kpt/internal/errors"
 	"github.com/GoogleContainerTools/kpt/internal/util/update"
 )
@@ -24,17 +26,6 @@ func init() {
 	AddErrorResolver(&updateErrorResolver{})
 }
 
-var (
-	//nolint:lll
-	pkgNotGitRepo = `
-Package {{ printf "%q" .repo }} is not within a git repository. Please initialize a repository using 'git init' and then commit the changes using 'git commit -m "<commit message>"'.
-`
-
-	pkgRepoDirty = `
-Package {{ printf "%q" .repo }} contains uncommitted changes. Please commit the changes using 'git commit -m "<commit message>"'.
-`
-)
-
 // updateErrorResolver is an implementation of the ErrorResolver interface
 // to resolve update errors.
 type updateErrorResolver struct{}
@@ -44,16 +35,15 @@ func (*updateErrorResolver) Resolve(err error) (ResolvedResult, bool) {
 
 	var pkgNotGitRepoError *update.PkgNotGitRepoError
 	if errors.As(err, &pkgNotGitRepoError) {
-		msg = ExecuteTemplate(pkgNotGitRepo, map[string]interface{}{
-			"repo": pkgNotGitRepoError.Path,
-		})
+		//nolint:lll
+		msg = fmt.Sprintf("Package %q is not within a git repository.", pkgNotGitRepoError.Path)
+		msg += " Please initialize a repository using 'git init' and then commit the changes using 'git commit -m \"<commit message>\"'."
 	}
 
 	var pkgRepoDirtyError *update.PkgRepoDirtyError
 	if errors.As(err, &pkgRepoDirtyError) {
-		msg = ExecuteTemplate(pkgRepoDirty, map[string]interface{}{
-			"repo": pkgRepoDirtyError.Path,
-		})
+		msg = fmt.Sprintf("Package %q contains uncommitted changes.", pkgRepoDirtyError.Path)
+		msg += " Please commit the changes using 'git commit -m \"<commit message>\"'."
 	}
 
 	if msg != "" {
diff --git a/internal/pkg/pkg.go b/internal/pkg/pkg.go
index 3e553fa7..7f9736df 100644
--- a/internal/pkg/pkg.go
+++ b/internal/pkg/pkg.go
@@ -64,7 +64,7 @@ type KptfileError struct {
 }
 
 func (k *KptfileError) Error() string {
-	return fmt.Sprintf("error reading Kptfile at %q: %s", k.Path.String(), k.Err.Error())
+	return fmt.Sprintf("error reading Kptfile at %q: %v", k.Path.String(), k.Err)
 }
 
 func (k *KptfileError) Unwrap() error {
diff --git a/release/formula/main.go b/release/formula/main.go
index fdb4af1e..f534e133 100644
--- a/release/formula/main.go
+++ b/release/formula/main.go
@@ -15,103 +15,84 @@
 package main
 
 import (
-	"bytes"
+	"context"
+	"crypto/sha256"
+	"encoding/hex"
 	"fmt"
+	"hash"
 	"io"
 	"net/http"
 	"os"
-	"os/exec"
 	"path/filepath"
 	"strings"
-	"text/template"
 )
 
 func main() {
-	if len(os.Args) < 2 {
-		fmt.Fprintf(os.Stderr, "must specify new version\n")
-		os.Exit(1)
-	}
-	input := Input{Version: os.Args[1]}
-	var err error
-	input.Sha, err = getSha(input.Version)
+	err := run(context.Background())
 	if err != nil {
+		fmt.Fprintf(os.Stderr, "%v\n", err)
 		os.Exit(1)
 	}
+}
 
-	// generate the formula text
-	t, err := template.New("formula").Parse(formula)
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "%v", err)
-		os.Exit(1)
+func run(ctx context.Context) error {
+	if len(os.Args) < 2 {
+		return fmt.Errorf("must specify new version")
 	}
 
-	// write the new formula
-	b := &bytes.Buffer{}
-	if err = t.Execute(b, input); err != nil {
-		fmt.Fprintf(os.Stderr, "%v", err)
-		os.Exit(1)
+	version := os.Args[1]
+	url := "https://github.com/GoogleContainerTools/kpt/archive/" + version + ".tar.gz"
+
+	formula, err := buildFormula(http.DefaultClient, url)
+	if err != nil {
+		return err
 	}
 
-	err = os.WriteFile(filepath.Join("Formula", "kpt.rb"), b.Bytes(), 0644)
+	err = os.WriteFile(filepath.Join("Formula", "kpt.rb"), []byte(formula), 0644)
 	if err != nil {
-		fmt.Fprintf(os.Stderr, "%v", err)
-		os.Exit(1)
+		return err
 	}
+	return nil
 }
 
-func getSha(version string) (string, error) {
-	// create the dir for the data
-	d, err := os.MkdirTemp("", "kpt-bin")
+func buildFormula(httpClient *http.Client, url string) (string, error) {
+	sha256, err := hashURL(httpClient, url, sha256.New())
 	if err != nil {
-		fmt.Fprintf(os.Stderr, "%v", err)
 		return "", err
 	}
-	defer os.RemoveAll(d)
 
-	fmt.Println(
-		"fetching https://github.com/GoogleContainerTools/kpt/archive/" + version + ".tar.gz")
+	// generate the formula text
+	formula := formulaTemplate
+	formula = strings.ReplaceAll(formula, "{{url}}", url)
+	formula = strings.ReplaceAll(formula, "{{sha256}}", sha256)
+
+	return formula, nil
+}
+
+func hashURL(httpClient *http.Client, url string, hasher hash.Hash) (string, error) {
+	fmt.Printf("fetching %q\n", url)
+
 	// get the content
-	resp, err := http.Get(
-		"https://github.com/GoogleContainerTools/kpt/archive/" + version + ".tar.gz")
+	resp, err := httpClient.Get(url)
 	if err != nil {
-		fmt.Fprintf(os.Stderr, "%v", err)
-		return "", err
+		return "", fmt.Errorf("error getting %q: %w", url, err)
 	}
 	defer resp.Body.Close()
 
-	// write the file
-	func() {
-		out, err := os.Create(filepath.Join(d, version+".tar.gz"))
-		if err != nil {
-			fmt.Fprintf(os.Stderr, "%v", err)
-			os.Exit(1)
-		}
-
-		if _, err = io.Copy(out, resp.Body); err != nil {
-			fmt.Fprintf(os.Stderr, "%v", err)
-			os.Exit(1)
-		}
-		out.Close()
-	}()
+	if resp.StatusCode != 200 {
+		return "", fmt.Errorf("unexpected response from %q: %v", url, resp.Status)
+	}
 
-	// calculate the sha
-	e := exec.Command("sha256sum", filepath.Join(d, version+".tar.gz"))
-	o, err := e.Output()
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "%v", err)
-		return "", err
+	if _, err := io.Copy(hasher, resp.Body); err != nil {
+		return "", fmt.Errorf("error hashing response from %q: %w", url, err)
 	}
-	parts := strings.Split(string(o), " ")
-	fmt.Println("new sha: " + parts[0])
-	return parts[0], nil
-}
 
-type Input struct {
-	Version string
-	Sha     string
+	// calculate the sha
+	hash := hasher.Sum(nil)
+	return hex.EncodeToString(hash), nil
 }
 
-const formula = `# Copyright 2019 Google LLC
+const formulaTemplate = `# Copyright 2019 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -128,8 +109,8 @@ const formula = `# Copyright 2019 Google LLC
 class Kpt < Formula
   desc "Toolkit to manage,and apply Kubernetes Resource config data files"
   homepage "https://googlecontainertools.github.io/kpt"
-  url "https://github.com/GoogleContainerTools/kpt/archive/{{.Version}}.tar.gz"
-  sha256 "{{.Sha}}"
+  url "{{url}}"
+  sha256 "{{sha256}}"
 
   depends_on "go" => :build
 


Test patch:
diff --git a/internal/testutil/pkgbuilder/builder.go b/internal/testutil/pkgbuilder/builder.go
index ac959686..2495d4fd 100644
--- a/internal/testutil/pkgbuilder/builder.go
+++ b/internal/testutil/pkgbuilder/builder.go
@@ -15,14 +15,12 @@
 package pkgbuilder
 
 import (
-	"bytes"
 	"fmt"
 	"os"
 	"path/filepath"
 	"regexp"
 	"strconv"
 	"testing"
-	"text/template"
 
 	kptfilev1 "github.com/GoogleContainerTools/kpt/pkg/api/kptfile/v1"
 	rgfilev1alpha1 "github.com/GoogleContainerTools/kpt/pkg/api/resourcegroup/v1alpha1"
@@ -561,53 +559,6 @@ func buildRGFile(pkg *pkg) string {
 	return string(b)
 }
 
-// TODO: Consider using the Kptfile struct for this instead of a template.
-var kptfileTemplate = `apiVersion: kpt.dev/v1
-kind: Kptfile
-metadata:
-  name: {{.PkgName}}
-{{- if .Pkg.Kptfile.Upstream }}
-upstream:
-  type: git
-  git:
-    repo: {{.Pkg.Kptfile.Upstream.Repo}}
-    directory: {{.Pkg.Kptfile.Upstream.Dir}}
-    ref: {{.Pkg.Kptfile.Upstream.Ref}}
-  updateStrategy: {{.Pkg.Kptfile.Upstream.Strategy}}
-{{- end }}
-{{- if .Pkg.Kptfile.UpstreamLock }}
-upstreamLock:
-  type: git
-  git:
-    repo: {{.Pkg.Kptfile.UpstreamLock.Repo}}
-    directory: {{.Pkg.Kptfile.UpstreamLock.Dir}}
-    ref: {{.Pkg.Kptfile.UpstreamLock.Ref}}
-    commit: {{.Pkg.Kptfile.UpstreamLock.Commit}}
-{{- end }}
-{{- if .Pkg.Kptfile.Pipeline }}
-pipeline:
-  mutators:
-{{- range .Pkg.Kptfile.Pipeline.Functions }}
-  - image: {{ .Image }}
-{{- if .ConfigPath }}
-  - configPath: {{ .ConfigPath }}
-{{- end }}
-{{- end }}
-{{- end }}
-{{- if .Pkg.Kptfile.Inventory }}
-inventory:
-{{- if .Pkg.Kptfile.Inventory.Name }}
-  name: {{ .Pkg.Kptfile.Inventory.Name }}
-{{- end }}
-{{- if .Pkg.Kptfile.Inventory.Namespace }}
-  namespace: {{ .Pkg.Kptfile.Inventory.Namespace }}
-{{- end }}
-{{- if .Pkg.Kptfile.Inventory.ID }}
-  inventoryID: {{ .Pkg.Kptfile.Inventory.ID }}
-{{- end }}
-{{- end }}
-`
-
 type ReposInfo interface {
 	ResolveRepoRef(repoRef string) (string, bool)
 	ResolveCommitIndex(repoRef string, index int) (string, bool)
@@ -635,20 +586,62 @@ func buildKptfile(pkg *pkg, pkgName string, reposInfo ReposInfo) string {
 			pkg.Kptfile.UpstreamLock.Ref = newRef
 		}
 	}
-	tmpl, err := template.New("test").Parse(kptfileTemplate)
-	if err != nil {
-		panic(err)
+
+	kptfile := &kptfilev1.KptFile{}
+	kptfile.APIVersion, kptfile.Kind = kptfilev1.KptFileGVK().ToAPIVersionAndKind()
+	kptfile.ObjectMeta.Name = pkgName
+	if pkg.Kptfile.Upstream != nil {
+		kptfile.Upstream = &kptfilev1.Upstream{
+			Type: "git",
+			Git: &kptfilev1.Git{
+				Repo:      pkg.Kptfile.Upstream.Repo,
+				Directory: pkg.Kptfile.Upstream.Dir,
+				Ref:       pkg.Kptfile.Upstream.Ref,
+			},
+			UpdateStrategy: kptfilev1.UpdateStrategyType(pkg.Kptfile.Upstream.Strategy),
+		}
 	}
-	var buf bytes.Buffer
-	err = tmpl.Execute(&buf, map[string]interface{}{
-		"Pkg":     pkg,
-		"PkgName": pkgName,
-	})
+	if pkg.Kptfile.UpstreamLock != nil {
+		kptfile.UpstreamLock = &kptfilev1.UpstreamLock{
+			Type: "git",
+			Git: &kptfilev1.GitLock{
+				Repo:      pkg.Kptfile.UpstreamLock.Repo,
+				Directory: pkg.Kptfile.UpstreamLock.Dir,
+				Ref:       pkg.Kptfile.UpstreamLock.Ref,
+				Commit:    pkg.Kptfile.UpstreamLock.Commit,
+			},
+		}
+	}
+	if pkg.Kptfile.Pipeline != nil {
+		kptfile.Pipeline = &kptfilev1.Pipeline{}
+		for _, fn := range pkg.Kptfile.Pipeline.Functions {
+			mutator := kptfilev1.Function{
+				Image: fn.Image,
+			}
+			if fn.ConfigPath != "" {
+				mutator.ConfigPath = fn.ConfigPath
+			}
+			kptfile.Pipeline.Mutators = append(kptfile.Pipeline.Mutators, mutator)
+		}
+	}
+
+	if inventory := pkg.Kptfile.Inventory; inventory != nil {
+		kptfile.Inventory = &kptfilev1.Inventory{}
+		if inventory.Name != "" {
+			kptfile.Inventory.Name = inventory.Name
+		}
+		if inventory.Namespace != "" {
+			kptfile.Inventory.Namespace = inventory.Namespace
+		}
+		if inventory.ID != "" {
+			kptfile.Inventory.InventoryID = inventory.ID
+		}
+	}
+	b, err := yaml.Marshal(kptfile)
 	if err != nil {
 		panic(err)
 	}
-	result := buf.String()
-	return result
+	return string(b)
 }
 
 // resolveRepoRef looks up the repo path for a repo from the reposInfo
diff --git a/internal/testutil/pkgbuilder/builder_test.go b/internal/testutil/pkgbuilder/builder_test.go
new file mode 100644
index 00000000..7e7e0ae8
--- /dev/null
+++ b/internal/testutil/pkgbuilder/builder_test.go
@@ -0,0 +1,37 @@
+package pkgbuilder
+
+import (
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestBuildKptfile(t *testing.T) {
+	var repos ReposInfo
+	pkg := &pkg{}
+	pkg.Kptfile = &Kptfile{
+		Pipeline: &Pipeline{
+			Functions: []Function{
+				{Image: "example.com/fn1"},
+				{ConfigPath: "config1"},
+				{Image: "example.com/fn2"},
+			},
+		},
+	}
+
+	got := buildKptfile(pkg, "test1", repos)
+	want := `apiVersion: kpt.dev/v1
+kind: Kptfile
+metadata:
+  name: test1
+pipeline:
+  mutators:
+  - image: example.com/fn1
+  - configPath: config1
+  - image: example.com/fn2
+`
+
+	if diff := cmp.Diff(want, got); diff != "" {
+		t.Errorf("buildKptfile returned unexpected diff (-want +got):\n%s", diff)
+	}
+}
diff --git a/release/formula/main_test.go b/release/formula/main_test.go
new file mode 100644
index 00000000..7ea62ebb
--- /dev/null
+++ b/release/formula/main_test.go
@@ -0,0 +1,87 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+import (
+	"bytes"
+	"io"
+	"net/http"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestFormula(t *testing.T) {
+	want := `# Copyright 2019 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+class Kpt < Formula
+  desc "Toolkit to manage,and apply Kubernetes Resource config data files"
+  homepage "https://googlecontainertools.github.io/kpt"
+  url "https://github.com/GoogleContainerTools/kpt/archive/v0.0.0-fake.tar.gz"
+  sha256 "4e42c5ce1a23511405beb5f51cfe07885fa953db448265fe74ee9b81e0def277"
+
+  depends_on "go" => :build
+
+  def install
+    ENV["GO111MODULE"] = "on"
+    system "go", "build", "-ldflags", "-X github.com/GoogleContainerTools/kpt/run.version=#{version}", *std_go_args
+  end
+
+  test do
+    assert_match version.to_s, shell_output("#{bin}/kpt version")
+  end
+end
+`
+
+	httpClient := &http.Client{
+		Transport: &fakeServer{t: t},
+	}
+	url := "https://github.com/GoogleContainerTools/kpt/archive/v0.0.0-fake.tar.gz"
+	got, err := buildFormula(httpClient, url)
+	if err != nil {
+		t.Fatalf("error from buildFormula(%q): %v", url, err)
+	}
+	if diff := cmp.Diff(want, got); diff != "" {
+		t.Errorf("buildFormula(%q) returned unexpected diff (-want +got):\n%s", url, diff)
+	}
+}
+
+type fakeServer struct {
+	t *testing.T
+}
+
+func (s *fakeServer) RoundTrip(req *http.Request) (*http.Response, error) {
+	if req.URL.Path != "/GoogleContainerTools/kpt/archive/v0.0.0-fake.tar.gz" {
+		s.t.Errorf("Expected to request '/GoogleContainerTools/kpt/archive/v0.0.0-fake.tar.gz', got: %s", req.URL.Path)
+	}
+	body := bytes.NewReader([]byte(`This is fake content so that our tests don't download big files`))
+	return &http.Response{
+		Status:     http.StatusText(http.StatusOK),
+		StatusCode: http.StatusOK,
+		Body:       io.NopCloser(body),
+	}, nil
+}