Repository: roboll/helmfile
Pull request #1189 (2020-04-08 12:30:24)

Update helmfile.yaml example in README
Added default comments into helmfile.yaml example

fixed some typos

Signed-off-by: Vadim Bauer <vb@8gears.com>

Vad1mo: @mumoshu could you do another review?

Patch:
diff --git a/README.md b/README.md
index fd01bef..d278eba 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,7 @@ To avoid upgrades for each iteration of `helm`, the `helmfile` executable delega
 
 **CAUTION**: This documentation is for the development version of Helmfile. If you are looking for the documentation for any of releases, please switch to the corresponding release tag like [v0.92.1](https://github.com/roboll/helmfile/tree/v0.92.1).
 
-The default helmfile is `helmfile.yaml`:
+The default name for a helmfile is `helmfile.yaml`:
 
 ```yaml
 # Chart repositories used from within this state file
@@ -69,30 +69,36 @@ repositories:
 
 # context: kube-context # this directive is deprecated, please consider using helmDefaults.kubeContext
 
-#default values to set for args along with dedicated keys that can be set by contributers, cli args take precedence over these
+# Default values to set for args along with dedicated keys that can be set by contributors, cli args take precedence over these. 
+# In other words, unset values results in no flags passed to helm.
+# See the helm usage (helm SUBCOMMAND -h) for more info on default values when those flags aren't provided.
 helmDefaults:
   tillerNamespace: tiller-namespace  #dedicated default key for tiller-namespace
   tillerless: false                  #dedicated default key for tillerless
   kubeContext: kube-context          #dedicated default key for kube-context (--kube-context)
   cleanupOnFail: false               #dedicated default key for helm flag --cleanup-on-fail
-  # additional and global args passed to helm
+  # additional and global args passed to helm (default "")
   args:
     - "--set k=v"
-  # defaults for verify, wait, force, timeout and recreatePods under releases[]
+  # verify the chart before upgrading (only works with packaged charts not directories) (default false)
   verify: true
+  # wait for k8s resources via --wait. (default false)
   wait: true
+  # time in seconds to wait for any individual Kubernetes operation (like Jobs for hooks, and waits on pod/pvc/svc/deployment readiness) (default 300)
   timeout: 600
+  # performs pods restart for the resource if applicable (default false)
   recreatePods: true
-  force: true
-  # enable TLS for request to Tiller
-  tls: true
+  # forces resource update through delete/recreate if needed (default false)
+  force: false
+  # enable TLS for request to Tiller (default false)
+  tls: true        
   # path to TLS CA certificate file (default "$HELM_HOME/ca.pem")
   tlsCACert: "path/to/ca.pem"
   # path to TLS certificate file (default "$HELM_HOME/cert.pem")
   tlsCert: "path/to/cert.pem"
   # path to TLS key file (default "$HELM_HOME/key.pem")
   tlsKey: "path/to/key.pem"
-  # limit the maximum number of revisions saved per release. Use 0 for no limit (default 10)
+  # limit the maximum number of revisions saved per release. Use 0 for no limit. (default 10) 
   historyMax: 10
 
 # The desired states of Helm releases.
@@ -102,7 +108,7 @@ releases:
   # Published chart example
   - name: vault                            # name of this release
     namespace: vault                       # target namespace
-    labels:                                  # Arbitrary key value pairs for filtering releases
+    labels:                                # Arbitrary key value pairs for filtering releases
       foo: bar
     chart: roboll/vault-secret-manager     # the chart being installed to create this release, referenced by `repository/chart` syntax
     version: ~1.24.1                       # the semver of the chart. range constraint is supported
@@ -145,28 +151,24 @@ releases:
       value: {{ .Namespace }}
     # will attempt to decrypt it using helm-secrets plugin
     secrets:
-      - vault_secret.yaml
-    # verify the chart before upgrading (only works with packaged charts not directories)
-    verify: true
-    # wait for k8s resources via --wait. Defaults to `false`
-    wait: true
-    # time in seconds to wait for any individual Kubernetes operation (like Jobs for hooks, and waits on pod/pvc/svc/deployment readiness) (default 300)
-    timeout: 60
-    # performs pods restart for the resource if applicable
-    recreatePods: true
-    # forces resource update through delete/recreate if needed
-    force: true
-    # set `false` to uninstall on sync
+      - vault_secret.yaml    
+    # Override helmDefaults options for verify, wait, timeout, recreatePods and force. 
+    verify: true              
+    wait: true            
+    timeout: 60           
+    recreatePods: true    
+    force: false          
+    # set `false` to uninstall this release on sync.  (default true)
     installed: true
-    # restores previous state in case of failed release
-    atomic: true
-    # when true, cleans up any new resources created during a failed release
-    cleanupOnFail: false
-    # name of the tiller namespace
-    tillerNamespace: vault
-    # if true, will use the helm-tiller plugin
+    # restores previous state in case of failed release (default false)
+    atomic: true          
+    # when true, cleans up any new resources created during a failed release (default false)
+    cleanupOnFail: false  
+    # name of the tiller namespace (default "") 
+    tillerNamespace: vault  
+    # if true, will use the helm-tiller plugin (default false)
     tillerless: false
-    # enable TLS for request to Tiller
+    # enable TLS for request to Tiller (default false)
     tls: true
     # path to TLS CA certificate file (default "$HELM_HOME/ca.pem")
     tlsCACert: "path/to/ca.pem"
@@ -177,6 +179,7 @@ releases:
     # --kube-context to be passed to helm commands
     # CAUTION: this doesn't work as expected for `tilerless: true`.
     # See https://github.com/roboll/helmfile/issues/642
+    # (default "", which means the standard kubeconfig, either ~/kubeconfig or the file pointed by $KUBECONFIG environment variable)
     kubeContext: kube-context
     # limit the maximum number of revisions saved per release. Use 0 for no limit (default 10)
     historyMax: 10
diff --git a/pkg/app/app.go b/pkg/app/app.go
index 3d37c34..bc1162e 100644
--- a/pkg/app/app.go
+++ b/pkg/app/app.go
@@ -116,12 +116,20 @@ func (a *App) DeprecatedSyncCharts(c DeprecatedChartsConfigProvider) error {
 }
 
 func (a *App) Diff(c DiffConfigProvider) error {
-	var deferredErrs []error
+	var allDiffDetectedErrs []error
 
-	err := a.ForEachStateFiltered(func(run *Run) []error {
+	var affectedAny bool
+
+	err := a.ForEachState(func(run *Run) (bool, []error) {
 		var criticalErrs []error
 
-		errs := run.Diff(c)
+		msg, matched, affected, errs := run.Diff(c)
+
+		if msg != nil {
+			a.Logger.Info(*msg)
+		}
+
+		affectedAny = affectedAny || affected
 
 		for i := range errs {
 			switch e := errs[i].(type) {
@@ -129,7 +137,7 @@ func (a *App) Diff(c DiffConfigProvider) error {
 				switch e.Code {
 				case 2:
 					// See https://github.com/roboll/helmfile/issues/874
-					deferredErrs = append(deferredErrs, e)
+					allDiffDetectedErrs = append(allDiffDetectedErrs, e)
 				default:
 					criticalErrs = append(criticalErrs, e)
 				}
@@ -138,14 +146,14 @@ func (a *App) Diff(c DiffConfigProvider) error {
 			}
 		}
 
-		return criticalErrs
+		return matched, criticalErrs
 	})
 
 	if err != nil {
 		return err
 	}
 
-	if len(deferredErrs) > 0 {
+	if c.DetailedExitcode() && (len(allDiffDetectedErrs) > 0 || affectedAny) {
 		// We take the first release error w/ exit status 2 (although all the defered errs should have exit status 2)
 		// to just let helmfile itself to exit with 2
 		// See https://github.com/roboll/helmfile/issues/749
@@ -806,84 +814,28 @@ func (a *App) apply(r *Run, c ApplyConfigProvider) (bool, bool, []error) {
 		Set:     c.Set(),
 	}
 
-	var changedReleases []state.ReleaseSpec
-	var deletingReleases []state.ReleaseSpec
-	var planningErrs []error
-
-	// TODO Better way to detect diff on only filtered releases
-	{
-		changedReleases, planningErrs = st.DiffReleases(helm, c.Values(), c.Concurrency(), detailedExitCode, c.IncludeTests(), c.SuppressSecrets(), c.SuppressDiff(), false, diffOpts)
-
-		var err error
-		deletingReleases, err = st.DetectReleasesToBeDeletedForSync(helm, st.Releases)
-		if err != nil {
-			planningErrs = append(planningErrs, err)
-		}
-	}
-
-	fatalErrs := []error{}
-
-	for _, e := range planningErrs {
-		switch err := e.(type) {
-		case *state.ReleaseError:
-			if err.Code != 2 {
-				fatalErrs = append(fatalErrs, e)
-			}
-		default:
-			fatalErrs = append(fatalErrs, e)
-		}
-	}
-
-	if len(fatalErrs) > 0 {
-		return false, false, fatalErrs
-	}
-
-	releasesToBeDeleted := map[string]state.ReleaseSpec{}
-	for _, r := range deletingReleases {
-		id := state.ReleaseToID(&r)
-		releasesToBeDeleted[id] = r
+	infoMsg, releasesToBeUpdated, releasesToBeDeleted, errs := r.diff(false, detailedExitCode, c, diffOpts)
+	if len(errs) > 0 {
+		return false, false, errs
 	}
 
-	releasesToBeUpdated := map[string]state.ReleaseSpec{}
-	for _, r := range changedReleases {
-		id := state.ReleaseToID(&r)
-
-		// If `helm-diff` detected changes but it is not being `helm delete`ed, we should run `helm upgrade`
-		if _, ok := releasesToBeDeleted[id]; !ok {
-			releasesToBeUpdated[id] = r
+	if releasesToBeDeleted == nil && releasesToBeUpdated == nil {
+		if infoMsg != nil {
+			logger := c.Logger()
+			logger.Infof("")
+			logger.Infof(*infoMsg)
 		}
-	}
-
-	// sync only when there are changes
-	if len(releasesToBeUpdated) == 0 && len(releasesToBeDeleted) == 0 {
-		// TODO better way to get the logger
-		logger := c.Logger()
-		logger.Infof("")
-		logger.Infof("No affected releases")
 		return true, false, nil
 	}
 
-	names := []string{}
-	for _, r := range releasesToBeUpdated {
-		names = append(names, fmt.Sprintf("  %s (%s) UPDATED", r.Name, r.Chart))
-	}
-	for _, r := range releasesToBeDeleted {
-		names = append(names, fmt.Sprintf("  %s (%s) DELETED", r.Name, r.Chart))
-	}
-	// Make the output deterministic for testing purpose
-	sort.Strings(names)
-
-	infoMsg := fmt.Sprintf(`Affected releases are:
-%s
-`, strings.Join(names, "\n"))
 	confMsg := fmt.Sprintf(`%s
 Do you really want to apply?
   Helmfile will apply all your changes, as shown above.
 
-`, infoMsg)
+`, *infoMsg)
 	interactive := c.Interactive()
 	if !interactive {
-		a.Logger.Debug(infoMsg)
+		a.Logger.Debug(*infoMsg)
 	}
 
 	syncErrs := []error{}
diff --git a/pkg/app/run.go b/pkg/app/run.go
index 75b741e..3dee83a 100644
--- a/pkg/app/run.go
+++ b/pkg/app/run.go
@@ -1,9 +1,12 @@
 package app
 
 import (
+	"fmt"
 	"github.com/roboll/helmfile/pkg/argparser"
 	"github.com/roboll/helmfile/pkg/helmexec"
 	"github.com/roboll/helmfile/pkg/state"
+	"sort"
+	"strings"
 )
 
 type Run struct {
@@ -61,21 +64,36 @@ func (r *Run) Status(c StatusesConfigProvider) []error {
 	return r.state.ReleaseStatuses(r.helm, workers)
 }
 
-func (r *Run) Diff(c DiffConfigProvider) []error {
+func (r *Run) Diff(c DiffConfigProvider) (*string, bool, bool, []error) {
 	st := r.state
 	helm := r.helm
 	ctx := r.ctx
 
+	allReleases := st.GetReleasesWithOverrides()
+
+	toDiff, err := st.GetSelectedReleasesWithOverrides()
+	if err != nil {
+		return nil, false, false, []error{err}
+	}
+
+	if len(toDiff) == 0 {
+		return nil, false, false, nil
+	}
+
+	// Do build deps and prepare only on selected releases so that we won't waste time
+	// on running various helm commands on unnecessary releases
+	st.Releases = toDiff
+
 	if !c.SkipDeps() {
 		if errs := ctx.SyncReposOnce(st, helm); errs != nil && len(errs) > 0 {
-			return errs
+			return nil, false, false, errs
 		}
 		if errs := st.BuildDeps(helm); errs != nil && len(errs) > 0 {
-			return errs
+			return nil, false, false, errs
 		}
 	}
 	if errs := st.PrepareReleases(helm, "diff"); errs != nil && len(errs) > 0 {
-		return errs
+		return nil, false, false, errs
 	}
 
 	r.helm.SetExtraArgs(argparser.GetArgs(c.Args(), r.state)...)
@@ -85,9 +103,28 @@ func (r *Run) Diff(c DiffConfigProvider) []error {
 		NoColor: c.NoColor(),
 		Set:     c.Set(),
 	}
-	_, errs := st.DiffReleases(helm, c.Values(), c.Concurrency(), c.DetailedExitcode(), c.IncludeTests(), c.SuppressSecrets(), c.SuppressDiff(), true, opts)
 
-	return errs
+	// Validate all releases for missing `needs` targets
+	st.Releases = allReleases
+
+	if _, err := st.PlanReleases(false); err != nil {
+		return nil, false, false, []error{err}
+	}
+
+	// Diff only targeted releases
+
+	st.Releases = toDiff
+
+	filtered := &Run{
+		state: st,
+		helm:  r.helm,
+		ctx:   r.ctx,
+		Ask:   r.Ask,
+	}
+
+	infoMsg, updated, deleted, errs := filtered.diff(true, c.DetailedExitcode(), c, opts)
+
+	return infoMsg, true, len(deleted) > 0 || len(updated) > 0, errs
 }
 
 func (r *Run) Test(c TestConfigProvider) []error {
@@ -124,3 +161,83 @@ func (r *Run) Lint(c LintConfigProvider) []error {
 	}
 	return st.LintReleases(helm, values, args, workers, opts)
 }
+
+func (run *Run) diff(triggerCleanupEvent bool, detailedExitCode bool, c DiffConfigProvider, diffOpts *state.DiffOpts) (*string, map[string]state.ReleaseSpec, map[string]state.ReleaseSpec, []error) {
+	st := run.state
+	helm := run.helm
+
+	var changedReleases []state.ReleaseSpec
+	var deletingReleases []state.ReleaseSpec
+	var planningErrs []error
+
+	// TODO Better way to detect diff on only filtered releases
+	{
+		changedReleases, planningErrs = st.DiffReleases(helm, c.Values(), c.Concurrency(), detailedExitCode, c.IncludeTests(), c.SuppressSecrets(), c.SuppressDiff(), triggerCleanupEvent, diffOpts)
+
+		var err error
+		deletingReleases, err = st.DetectReleasesToBeDeletedForSync(helm, st.Releases)
+		if err != nil {
+			planningErrs = append(planningErrs, err)
+		}
+	}
+
+	fatalErrs := []error{}
+
+	for _, e := range planningErrs {
+		switch err := e.(type) {
+		case *state.ReleaseError:
+			if err.Code != 2 {
+				fatalErrs = append(fatalErrs, e)
+			}
+		default:
+			fatalErrs = append(fatalErrs, e)
+		}
+	}
+
+	if len(fatalErrs) > 0 {
+		return nil, nil, nil, fatalErrs
+	}
+
+	releasesToBeDeleted := map[string]state.ReleaseSpec{}
+	for _, r := range deletingReleases {
+		id := state.ReleaseToID(&r)
+		releasesToBeDeleted[id] = r
+	}
+
+	releasesToBeUpdated := map[string]state.ReleaseSpec{}
+	for _, r := range changedReleases {
+		id := state.ReleaseToID(&r)
+
+		// If `helm-diff` detected changes but it is not being `helm delete`ed, we should run `helm upgrade`
+		if _, ok := releasesToBeDeleted[id]; !ok {
+			releasesToBeUpdated[id] = r
+		}
+	}
+
+	// sync only when there are changes
+	if len(releasesToBeUpdated) == 0 && len(releasesToBeDeleted) == 0 {
+		var msg *string
+		if c.DetailedExitcode() {
+			// TODO better way to get the logger
+			m := "No affected releases"
+			msg = &m
+		}
+		return msg, nil, nil, nil
+	}
+
+	names := []string{}
+	for _, r := range releasesToBeUpdated {
+		names = append(names, fmt.Sprintf("  %s (%s) UPDATED", r.Name, r.Chart))
+	}
+	for _, r := range releasesToBeDeleted {
+		names = append(names, fmt.Sprintf("  %s (%s) DELETED", r.Name, r.Chart))
+	}
+	// Make the output deterministic for testing purpose
+	sort.Strings(names)
+
+	infoMsg := fmt.Sprintf(`Affected releases are:
+%s
+`, strings.Join(names, "\n"))
+
+	return &infoMsg, releasesToBeUpdated, releasesToBeDeleted, nil
+}
diff --git a/pkg/state/create.go b/pkg/state/create.go
index 3315d74..67efa63 100644
--- a/pkg/state/create.go
+++ b/pkg/state/create.go
@@ -235,7 +235,7 @@ func (c *StateCreator) loadEnvValues(st *HelmState, name string, failOnMissingEn
 	if ctxEnv != nil {
 		intEnv := *ctxEnv
 
-		if err := mergo.Merge(&intEnv, newEnv, mergo.WithOverride); err != nil {
+		if err := mergo.Merge(&intEnv, newEnv, mergo.WithOverride, mergo.WithOverwriteWithEmptyValue); err != nil {
 			return nil, fmt.Errorf("error while merging environment values for \"%s\": %v", name, err)
 		}
 
@@ -303,7 +303,7 @@ func (c *StateCreator) scatterGatherEnvSecretFiles(st *HelmState, envSecretFiles
 				if result.err != nil {
 					errs = append(errs, result.err)
 				} else {
-					if err := mergo.Merge(&envVals, &result.result, mergo.WithOverride); err != nil {
+					if err := mergo.Merge(&envVals, &result.result, mergo.WithOverride, mergo.WithOverwriteWithEmptyValue); err != nil {
 						errs = append(errs, fmt.Errorf("failed to load environment secrets file \"%s\": %v", result.path, err))
 					}
 				}
diff --git a/pkg/state/state.go b/pkg/state/state.go
index b6163dd..3afd885 100644
--- a/pkg/state/state.go
+++ b/pkg/state/state.go
@@ -1327,6 +1327,15 @@ func (st *HelmState) GetSelectedReleasesWithOverrides() ([]ReleaseSpec, error) {
 			releases = append(releases, r.ReleaseSpec)
 		}
 	}
+
+	var extra string
+
+	if len(st.Selectors) > 0 {
+		extra = " matching " + strings.Join(st.Selectors, ",")
+	}
+
+	st.logger.Debugf("%d release(s)%s found in %s\n", len(releases), extra, st.FilePath)
+
 	return releases, nil
 }
 
@@ -1337,7 +1346,6 @@ func (st *HelmState) FilterReleases() error {
 		return err
 	}
 	st.Releases = releases
-	st.logger.Debugf("%d release(s) matching %s found in %s\n", len(releases), strings.Join(st.Selectors, ","), st.FilePath)
 	return nil
 }
 
diff --git a/pkg/state/state_exec_tmpl.go b/pkg/state/state_exec_tmpl.go
index c68ffbc..aa590ec 100644
--- a/pkg/state/state_exec_tmpl.go
+++ b/pkg/state/state_exec_tmpl.go
@@ -13,10 +13,10 @@ import (
 func (st *HelmState) Values() (map[string]interface{}, error) {
 	vals := map[string]interface{}{}
 
-	if err := mergo.Merge(&vals, st.Env.Defaults, mergo.WithOverride); err != nil {
+	if err := mergo.Merge(&vals, st.Env.Defaults, mergo.WithOverride, mergo.WithOverwriteWithEmptyValue); err != nil {
 		return nil, err
 	}
-	if err := mergo.Merge(&vals, st.Env.Values, mergo.WithOverride); err != nil {
+	if err := mergo.Merge(&vals, st.Env.Values, mergo.WithOverride, mergo.WithOverwriteWithEmptyValue); err != nil {
 		return nil, err
 	}
 
diff --git a/pkg/tmpl/context_funcs.go b/pkg/tmpl/context_funcs.go
index 0819d9e..025102f 100644
--- a/pkg/tmpl/context_funcs.go
+++ b/pkg/tmpl/context_funcs.go
@@ -26,6 +26,7 @@ func (c *Context) createFuncMap() template.FuncMap {
 		"get":            get,
 		"getOrNil":       getOrNil,
 		"tpl":            c.Tpl,
+		"required":       Required,
 	}
 	if c.preRender {
 		// disable potential side-effect template calls
@@ -242,3 +243,15 @@ func RequiredEnv(name string) (string, error) {
 
 	return "", fmt.Errorf("required env var `%s` is not set", name)
 }
+
+func Required(warn string, val interface{}) (interface{}, error) {
+	if val == nil {
+		return nil, fmt.Errorf(warn)
+	} else if _, ok := val.(string); ok {
+		if val == "" {
+			return nil, fmt.Errorf(warn)
+		}
+	}
+
+	return val, nil
+}


Test patch:
diff --git a/pkg/app/app_test.go b/pkg/app/app_test.go
index d35d786..ddcbcce 100644
--- a/pkg/app/app_test.go
+++ b/pkg/app/app_test.go
@@ -2524,6 +2524,8 @@ second-pass rendering result of "helmfile.yaml.part.0":
 54: 
 
 merged environment: &{default map[] map[]}
+10 release(s) found in helmfile.yaml
+
 worker 1/1 started
 worker 1/1 finished
 worker 1/1 started
@@ -2711,6 +2713,8 @@ second-pass rendering result of "helmfile.yaml.part.0":
 10: 
 
 merged environment: &{default map[] map[]}
+3 release(s) found in helmfile.yaml
+
 worker 1/1 started
 worker 1/1 finished
 worker 1/1 started
@@ -2989,6 +2993,8 @@ second-pass rendering result of "helmfile.yaml.part.0":
 12: 
 
 merged environment: &{default map[] map[]}
+2 release(s) found in helmfile.yaml
+
 worker 1/1 started
 worker 1/1 finished
 worker 1/1 started
@@ -3335,6 +3341,8 @@ second-pass rendering result of "helmfile.yaml.part.0":
 23: 
 
 merged environment: &{default map[] map[]}
+2 release(s) matching app=test found in helmfile.yaml
+
 worker 1/1 started
 worker 1/1 finished
 worker 1/1 started
@@ -3473,6 +3481,8 @@ second-pass rendering result of "helmfile.yaml.part.0":
 23: 
 
 merged environment: &{default map[] map[]}
+0 release(s) matching app=test_non_existent found in helmfile.yaml
+
 `,
 		},
 		//
@@ -3535,6 +3545,8 @@ second-pass rendering result of "helmfile.yaml.part.0":
  9: 
 
 merged environment: &{default map[] map[]}
+2 release(s) found in helmfile.yaml
+
 worker 1/1 started
 worker 1/1 finished
 worker 1/1 started
diff --git a/pkg/app/destroy_test.go b/pkg/app/destroy_test.go
index 4fd21b8..c715734 100644
--- a/pkg/app/destroy_test.go
+++ b/pkg/app/destroy_test.go
@@ -284,6 +284,8 @@ second-pass rendering result of "helmfile.yaml.part.0":
 54: 
 
 merged environment: &{default map[] map[]}
+10 release(s) found in helmfile.yaml
+
 processing 5 groups of releases in this order:
 GROUP RELEASES
 1     frontend-v3, frontend-v2, frontend-v1
diff --git a/pkg/app/diff_test.go b/pkg/app/diff_test.go
new file mode 100644
index 0000000..6abf91b
--- /dev/null
+++ b/pkg/app/diff_test.go
@@ -0,0 +1,1308 @@
+package app
+
+import (
+	"bufio"
+	"bytes"
+	"github.com/roboll/helmfile/pkg/exectest"
+	"github.com/roboll/helmfile/pkg/helmexec"
+	"github.com/roboll/helmfile/pkg/testhelper"
+	"github.com/variantdev/vals"
+	"go.uber.org/zap"
+	"io"
+	"path/filepath"
+	"sync"
+	"testing"
+)
+
+type diffConfig struct {
+	args              string
+	values            []string
+	retainValuesFiles bool
+	set               []string
+	validate          bool
+	skipDeps          bool
+	includeTests      bool
+	suppressSecrets   bool
+	suppressDiff      bool
+	noColor           bool
+	context           int
+	concurrency       int
+	detailedExitcode  bool
+	interactive       bool
+	logger            *zap.SugaredLogger
+}
+
+func (a diffConfig) Args() string {
+	return a.args
+}
+
+func (a diffConfig) Values() []string {
+	return a.values
+}
+
+func (a diffConfig) Set() []string {
+	return a.set
+}
+
+func (a diffConfig) Validate() bool {
+	return a.validate
+}
+
+func (a diffConfig) SkipDeps() bool {
+	return a.skipDeps
+}
+
+func (a diffConfig) IncludeTests() bool {
+	return a.includeTests
+}
+
+func (a diffConfig) SuppressSecrets() bool {
+	return a.suppressSecrets
+}
+
+func (a diffConfig) SuppressDiff() bool {
+	return a.suppressDiff
+}
+
+func (a diffConfig) NoColor() bool {
+	return a.noColor
+}
+
+func (a diffConfig) Context() int {
+	return a.context
+}
+
+func (a diffConfig) Concurrency() int {
+	return a.concurrency
+}
+
+func (a diffConfig) DetailedExitcode() bool {
+	return a.detailedExitcode
+}
+
+func (a diffConfig) Interactive() bool {
+	return a.interactive
+}
+
+func (a diffConfig) Logger() *zap.SugaredLogger {
+	return a.logger
+}
+
+func (a diffConfig) RetainValuesFiles() bool {
+	return a.retainValuesFiles
+}
+
+func TestDiff(t *testing.T) {
+	testcases := []struct {
+		name             string
+		loc              string
+		ns               string
+		concurrency      int
+		detailedExitcode bool
+		error            string
+		files            map[string]string
+		selectors        []string
+		lists            map[exectest.ListKey]string
+		diffs            map[exectest.DiffKey]error
+		upgraded         []exectest.Release
+		deleted          []exectest.Release
+		log              string
+	}{
+		//
+		// complex test cases for smoke testing
+		//
+		{
+			name: "smoke",
+			loc:  location(),
+			files: map[string]string{
+				"/path/to/helmfile.yaml": `
+releases:
+- name: database
+  chart: charts/mysql
+  needs:
+  - logging
+- name: frontend-v1
+  chart: charts/frontend
+  installed: false
+  needs:
+  - servicemesh
+  - logging
+  - backend-v1
+- name: frontend-v2
+  chart: charts/frontend
+  needs:
+  - servicemesh
+  - logging
+  - backend-v2
+- name: frontend-v3
+  chart: charts/frontend
+  needs:
+  - servicemesh
+  - logging
+  - backend-v2
+- name: backend-v1
+  chart: charts/backend
+  installed: false
+  needs:
+  - servicemesh
+  - logging
+  - database
+  - anotherbackend
+- name: backend-v2
+  chart: charts/backend
+  needs:
+  - servicemesh
+  - logging
+  - database
+  - anotherbackend
+- name: anotherbackend
+  chart: charts/anotherbackend
+  needs:
+  - servicemesh
+  - logging
+  - database
+- name: servicemesh
+  chart: charts/istio
+  needs:
+  - logging
+- name: logging
+  chart: charts/fluent-bit
+- name: front-proxy
+  chart: stable/envoy
+`,
+			},
+			detailedExitcode: true,
+			error:            "Identified at least on change",
+			diffs: map[exectest.DiffKey]error{
+				// noop on frontend-v2
+				exectest.DiffKey{Name: "frontend-v2", Chart: "charts/frontend", Flags: "--kube-contextdefault--detailed-exitcode"}: nil,
+				// install frontend-v3
+				exectest.DiffKey{Name: "frontend-v3", Chart: "charts/frontend", Flags: "--kube-contextdefault--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+				// upgrades
+				exectest.DiffKey{Name: "logging", Chart: "charts/fluent-bit", Flags: "--kube-contextdefault--detailed-exitcode"}:            helmexec.ExitError{Code: 2},
+				exectest.DiffKey{Name: "front-proxy", Chart: "stable/envoy", Flags: "--kube-contextdefault--detailed-exitcode"}:             helmexec.ExitError{Code: 2},
+				exectest.DiffKey{Name: "servicemesh", Chart: "charts/istio", Flags: "--kube-contextdefault--detailed-exitcode"}:             helmexec.ExitError{Code: 2},
+				exectest.DiffKey{Name: "database", Chart: "charts/mysql", Flags: "--kube-contextdefault--detailed-exitcode"}:                helmexec.ExitError{Code: 2},
+				exectest.DiffKey{Name: "backend-v2", Chart: "charts/backend", Flags: "--kube-contextdefault--detailed-exitcode"}:            helmexec.ExitError{Code: 2},
+				exectest.DiffKey{Name: "anotherbackend", Chart: "charts/anotherbackend", Flags: "--kube-contextdefault--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+			},
+			lists: map[exectest.ListKey]string{
+				// delete frontend-v1 and backend-v1
+				exectest.ListKey{Filter: "^frontend-v1$", Flags: "--kube-contextdefault--deployed--failed--pending"}: `NAME	REVISION	UPDATED                 	STATUS  	CHART        	APP VERSION	NAMESPACE
+frontend-v1 	4       	Fri Nov  1 08:40:07 2019	DEPLOYED	backend-3.1.0	3.1.0      	default
+`,
+				exectest.ListKey{Filter: "^backend-v1$", Flags: "--kube-contextdefault--deployed--failed--pending"}: `NAME	REVISION	UPDATED                 	STATUS  	CHART        	APP VERSION	NAMESPACE
+backend-v1 	4       	Fri Nov  1 08:40:07 2019	DEPLOYED	backend-3.1.0	3.1.0      	default
+`,
+			},
+			// Disable concurrency to avoid in-deterministic result
+			concurrency: 1,
+			upgraded:    []exectest.Release{},
+			deleted:     []exectest.Release{},
+			log: `processing file "helmfile.yaml" in directory "."
+first-pass rendering starting for "helmfile.yaml.part.0": inherited=&{default map[] map[]}, overrode=<nil>
+first-pass uses: &{default map[] map[]}
+first-pass rendering output of "helmfile.yaml.part.0":
+ 0: 
+ 1: releases:
+ 2: - name: database
+ 3:   chart: charts/mysql
+ 4:   needs:
+ 5:   - logging
+ 6: - name: frontend-v1
+ 7:   chart: charts/frontend
+ 8:   installed: false
+ 9:   needs:
+10:   - servicemesh
+11:   - logging
+12:   - backend-v1
+13: - name: frontend-v2
+14:   chart: charts/frontend
+15:   needs:
+16:   - servicemesh
+17:   - logging
+18:   - backend-v2
+19: - name: frontend-v3
+20:   chart: charts/frontend
+21:   needs:
+22:   - servicemesh
+23:   - logging
+24:   - backend-v2
+25: - name: backend-v1
+26:   chart: charts/backend
+27:   installed: false
+28:   needs:
+29:   - servicemesh
+30:   - logging
+31:   - database
+32:   - anotherbackend
+33: - name: backend-v2
+34:   chart: charts/backend
+35:   needs:
+36:   - servicemesh
+37:   - logging
+38:   - database
+39:   - anotherbackend
+40: - name: anotherbackend
+41:   chart: charts/anotherbackend
+42:   needs:
+43:   - servicemesh
+44:   - logging
+45:   - database
+46: - name: servicemesh
+47:   chart: charts/istio
+48:   needs:
+49:   - logging
+50: - name: logging
+51:   chart: charts/fluent-bit
+52: - name: front-proxy
+53:   chart: stable/envoy
+54: 
+
+first-pass produced: &{default map[] map[]}
+first-pass rendering result of "helmfile.yaml.part.0": {default map[] map[]}
+vals:
+map[]
+defaultVals:[]
+second-pass rendering result of "helmfile.yaml.part.0":
+ 0: 
+ 1: releases:
+ 2: - name: database
+ 3:   chart: charts/mysql
+ 4:   needs:
+ 5:   - logging
+ 6: - name: frontend-v1
+ 7:   chart: charts/frontend
+ 8:   installed: false
+ 9:   needs:
+10:   - servicemesh
+11:   - logging
+12:   - backend-v1
+13: - name: frontend-v2
+14:   chart: charts/frontend
+15:   needs:
+16:   - servicemesh
+17:   - logging
+18:   - backend-v2
+19: - name: frontend-v3
+20:   chart: charts/frontend
+21:   needs:
+22:   - servicemesh
+23:   - logging
+24:   - backend-v2
+25: - name: backend-v1
+26:   chart: charts/backend
+27:   installed: false
+28:   needs:
+29:   - servicemesh
+30:   - logging
+31:   - database
+32:   - anotherbackend
+33: - name: backend-v2
+34:   chart: charts/backend
+35:   needs:
+36:   - servicemesh
+37:   - logging
+38:   - database
+39:   - anotherbackend
+40: - name: anotherbackend
+41:   chart: charts/anotherbackend
+42:   needs:
+43:   - servicemesh
+44:   - logging
+45:   - database
+46: - name: servicemesh
+47:   chart: charts/istio
+48:   needs:
+49:   - logging
+50: - name: logging
+51:   chart: charts/fluent-bit
+52: - name: front-proxy
+53:   chart: stable/envoy
+54: 
+
+merged environment: &{default map[] map[]}
+10 release(s) found in helmfile.yaml
+
+worker 1/1 started
+worker 1/1 finished
+worker 1/1 started
+worker 1/1 finished
+Affected releases are:
+  anotherbackend (charts/anotherbackend) UPDATED
+  backend-v1 (charts/backend) DELETED
+  backend-v2 (charts/backend) UPDATED
+  database (charts/mysql) UPDATED
+  front-proxy (stable/envoy) UPDATED
+  frontend-v1 (charts/frontend) DELETED
+  frontend-v3 (charts/frontend) UPDATED
+  logging (charts/fluent-bit) UPDATED
+  servicemesh (charts/istio) UPDATED
+
+`,
+		},
+		//
+		// noop: no changes
+		//
+		{
+			name: "noop",
+			loc:  location(),
+			files: map[string]string{
+				"/path/to/helmfile.yaml": `
+releases:
+- name: bar
+  chart: mychart2
+- name: foo
+  chart: mychart1
+  installed: false
+  needs:
+  - bar
+`,
+			},
+			detailedExitcode: true,
+			error:            "",
+			diffs: map[exectest.DiffKey]error{
+				exectest.DiffKey{Name: "bar", Chart: "mychart2", Flags: "--kube-contextdefault--detailed-exitcode"}: nil,
+			},
+			lists: map[exectest.ListKey]string{
+				exectest.ListKey{Filter: "^foo$", Flags: "--kube-contextdefault--deployed--failed--pending"}: ``,
+				exectest.ListKey{Filter: "^bar$", Flags: "--kube-contextdefault--deployed--failed--pending"}: `NAME	REVISION	UPDATED                 	STATUS  	CHART        	APP VERSION	NAMESPACE
+bar 	4       	Fri Nov  1 08:40:07 2019	DEPLOYED	mychart2-3.1.0	3.1.0      	default
+`,
+			},
+			upgraded: []exectest.Release{},
+			deleted:  []exectest.Release{},
+		},
+		//
+		// install
+		//
+		{
+			name: "install",
+			loc:  location(),
+			files: map[string]string{
+				"/path/to/helmfile.yaml": `
+releases:
+- name: baz
+  chart: mychart3
+- name: foo
+  chart: mychart1
+  needs:
+  - bar
+- name: bar
+  chart: mychart2
+`,
+			},
+			detailedExitcode: true,
+			error:            "Identified at least on change",
+			diffs: map[exectest.DiffKey]error{
+				exectest.DiffKey{Name: "foo", Chart: "mychart1", Flags: "--kube-contextdefault--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+				exectest.DiffKey{Name: "bar", Chart: "mychart2", Flags: "--kube-contextdefault--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+				exectest.DiffKey{Name: "baz", Chart: "mychart3", Flags: "--kube-contextdefault--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+			},
+			lists:       map[exectest.ListKey]string{},
+			upgraded:    []exectest.Release{},
+			deleted:     []exectest.Release{},
+			concurrency: 1,
+			log: `processing file "helmfile.yaml" in directory "."
+first-pass rendering starting for "helmfile.yaml.part.0": inherited=&{default map[] map[]}, overrode=<nil>
+first-pass uses: &{default map[] map[]}
+first-pass rendering output of "helmfile.yaml.part.0":
+ 0: 
+ 1: releases:
+ 2: - name: baz
+ 3:   chart: mychart3
+ 4: - name: foo
+ 5:   chart: mychart1
+ 6:   needs:
+ 7:   - bar
+ 8: - name: bar
+ 9:   chart: mychart2
+10: 
+
+first-pass produced: &{default map[] map[]}
+first-pass rendering result of "helmfile.yaml.part.0": {default map[] map[]}
+vals:
+map[]
+defaultVals:[]
+second-pass rendering result of "helmfile.yaml.part.0":
+ 0: 
+ 1: releases:
+ 2: - name: baz
+ 3:   chart: mychart3
+ 4: - name: foo
+ 5:   chart: mychart1
+ 6:   needs:
+ 7:   - bar
+ 8: - name: bar
+ 9:   chart: mychart2
+10: 
+
+merged environment: &{default map[] map[]}
+3 release(s) found in helmfile.yaml
+
+worker 1/1 started
+worker 1/1 finished
+worker 1/1 started
+worker 1/1 finished
+Affected releases are:
+  bar (mychart2) UPDATED
+  baz (mychart3) UPDATED
+  foo (mychart1) UPDATED
+
+`,
+		},
+		//
+		// upgrades
+		//
+		{
+			name: "upgrade when foo needs bar",
+			loc:  location(),
+			files: map[string]string{
+				"/path/to/helmfile.yaml": `
+releases:
+- name: bar
+  chart: mychart2
+- name: foo
+  chart: mychart1
+  needs:
+  - bar
+`,
+			},
+			detailedExitcode: true,
+			error:            "Identified at least on change",
+			diffs: map[exectest.DiffKey]error{
+				exectest.DiffKey{Name: "bar", Chart: "mychart2", Flags: "--kube-contextdefault--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+				exectest.DiffKey{Name: "foo", Chart: "mychart1", Flags: "--kube-contextdefault--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+			},
+			upgraded: []exectest.Release{},
+		},
+		{
+			name: "upgrade when bar needs foo",
+			loc:  location(),
+			files: map[string]string{
+				"/path/to/helmfile.yaml": `
+releases:
+- name: foo
+  chart: mychart1
+- name: bar
+  chart: mychart2
+  needs:
+  - foo
+`,
+			},
+			detailedExitcode: true,
+			error:            "Identified at least on change",
+			diffs: map[exectest.DiffKey]error{
+				exectest.DiffKey{Name: "bar", Chart: "mychart2", Flags: "--kube-contextdefault--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+				exectest.DiffKey{Name: "foo", Chart: "mychart1", Flags: "--kube-contextdefault--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+			},
+			upgraded: []exectest.Release{},
+		},
+		{
+			name: "upgrade when foo needs bar, with ns override",
+			loc:  location(),
+			ns:   "testNamespace",
+			files: map[string]string{
+				"/path/to/helmfile.yaml": `
+releases:
+- name: bar
+  chart: mychart2
+- name: foo
+  chart: mychart1
+  needs:
+  - bar
+`,
+			},
+			detailedExitcode: true,
+			error:            "Identified at least on change",
+			diffs: map[exectest.DiffKey]error{
+				exectest.DiffKey{Name: "bar", Chart: "mychart2", Flags: "--kube-contextdefault--namespacetestNamespace--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+				exectest.DiffKey{Name: "foo", Chart: "mychart1", Flags: "--kube-contextdefault--namespacetestNamespace--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+			},
+			upgraded: []exectest.Release{},
+		},
+		{
+			name: "upgrade when bar needs foo, with ns override",
+			loc:  location(),
+			ns:   "testNamespace",
+			files: map[string]string{
+				"/path/to/helmfile.yaml": `
+releases:
+- name: foo
+  chart: mychart1
+- name: bar
+  chart: mychart2
+  needs:
+  - foo
+`,
+			},
+			detailedExitcode: true,
+			error:            "Identified at least on change",
+			diffs: map[exectest.DiffKey]error{
+				exectest.DiffKey{Name: "bar", Chart: "mychart2", Flags: "--kube-contextdefault--namespacetestNamespace--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+				exectest.DiffKey{Name: "foo", Chart: "mychart1", Flags: "--kube-contextdefault--namespacetestNamespace--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+			},
+			upgraded: []exectest.Release{},
+		},
+		{
+			name: "upgrade when ns1/foo needs ns2/bar",
+			loc:  location(),
+			files: map[string]string{
+				"/path/to/helmfile.yaml": `
+releases:
+- name: foo
+  chart: mychart1
+  namespace: ns1
+  needs:
+  - ns2/bar
+- name: bar
+  chart: mychart2
+  namespace: ns2
+`,
+			},
+			detailedExitcode: true,
+			error:            "Identified at least on change",
+			diffs: map[exectest.DiffKey]error{
+				exectest.DiffKey{Name: "bar", Chart: "mychart2", Flags: "--kube-contextdefault--namespacens2--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+				exectest.DiffKey{Name: "foo", Chart: "mychart1", Flags: "--kube-contextdefault--namespacens1--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+			},
+			upgraded: []exectest.Release{},
+		},
+		{
+			name: "upgrade when ns2/bar needs ns1/foo",
+			loc:  location(),
+			files: map[string]string{
+				"/path/to/helmfile.yaml": `
+releases:
+- name: bar
+  chart: mychart2
+  namespace: ns2
+  needs:
+  - ns1/foo
+- name: foo
+  chart: mychart1
+  namespace: ns1
+`,
+			},
+			detailedExitcode: true,
+			error:            "Identified at least on change",
+			diffs: map[exectest.DiffKey]error{
+				exectest.DiffKey{Name: "bar", Chart: "mychart2", Flags: "--kube-contextdefault--namespacens2--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+				exectest.DiffKey{Name: "foo", Chart: "mychart1", Flags: "--kube-contextdefault--namespacens1--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+			},
+			upgraded: []exectest.Release{},
+		},
+		{
+			name: "upgrade when tns1/ns1/foo needs tns2/ns2/bar",
+			loc:  location(),
+
+			files: map[string]string{
+				"/path/to/helmfile.yaml": `
+releases:
+- name: foo
+  chart: mychart1
+  namespace: ns1
+  tillerNamespace: tns1
+  needs:
+  - tns2/ns2/bar
+- name: bar
+  chart: mychart2
+  namespace: ns2
+  tillerNamespace: tns2
+`,
+			},
+			detailedExitcode: true,
+			error:            "Identified at least on change",
+			diffs: map[exectest.DiffKey]error{
+				exectest.DiffKey{Name: "bar", Chart: "mychart2", Flags: "--tiller-namespacetns2--kube-contextdefault--namespacens2--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+				exectest.DiffKey{Name: "foo", Chart: "mychart1", Flags: "--tiller-namespacetns1--kube-contextdefault--namespacens1--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+			},
+			upgraded: []exectest.Release{},
+		},
+		{
+			name: "upgrade when tns2/ns2/bar needs tns1/ns1/foo",
+			loc:  location(),
+			files: map[string]string{
+				"/path/to/helmfile.yaml": `
+releases:
+- name: bar
+  chart: mychart2
+  namespace: ns2
+  tillerNamespace: tns2
+  needs:
+  - tns1/ns1/foo
+- name: foo
+  chart: mychart1
+  namespace: ns1
+  tillerNamespace: tns1
+`,
+			},
+			detailedExitcode: true,
+			error:            "Identified at least on change",
+			diffs: map[exectest.DiffKey]error{
+				exectest.DiffKey{Name: "bar", Chart: "mychart2", Flags: "--tiller-namespacetns2--kube-contextdefault--namespacens2--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+				exectest.DiffKey{Name: "foo", Chart: "mychart1", Flags: "--tiller-namespacetns1--kube-contextdefault--namespacens1--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+			},
+			upgraded: []exectest.Release{},
+			// as we check for log output, set concurrency to 1 to avoid non-deterministic test result
+			concurrency: 1,
+			log: `processing file "helmfile.yaml" in directory "."
+first-pass rendering starting for "helmfile.yaml.part.0": inherited=&{default map[] map[]}, overrode=<nil>
+first-pass uses: &{default map[] map[]}
+first-pass rendering output of "helmfile.yaml.part.0":
+ 0: 
+ 1: releases:
+ 2: - name: bar
+ 3:   chart: mychart2
+ 4:   namespace: ns2
+ 5:   tillerNamespace: tns2
+ 6:   needs:
+ 7:   - tns1/ns1/foo
+ 8: - name: foo
+ 9:   chart: mychart1
+10:   namespace: ns1
+11:   tillerNamespace: tns1
+12: 
+
+first-pass produced: &{default map[] map[]}
+first-pass rendering result of "helmfile.yaml.part.0": {default map[] map[]}
+vals:
+map[]
+defaultVals:[]
+second-pass rendering result of "helmfile.yaml.part.0":
+ 0: 
+ 1: releases:
+ 2: - name: bar
+ 3:   chart: mychart2
+ 4:   namespace: ns2
+ 5:   tillerNamespace: tns2
+ 6:   needs:
+ 7:   - tns1/ns1/foo
+ 8: - name: foo
+ 9:   chart: mychart1
+10:   namespace: ns1
+11:   tillerNamespace: tns1
+12: 
+
+merged environment: &{default map[] map[]}
+2 release(s) found in helmfile.yaml
+
+worker 1/1 started
+worker 1/1 finished
+worker 1/1 started
+worker 1/1 finished
+Affected releases are:
+  bar (mychart2) UPDATED
+  foo (mychart1) UPDATED
+
+`,
+		},
+		//
+		// deletes: deleting all releases in the correct order
+		//
+		{
+			name: "delete foo and bar when foo needs bar",
+			loc:  location(),
+			files: map[string]string{
+				"/path/to/helmfile.yaml": `
+releases:
+- name: bar
+  chart: mychart2
+  installed: false
+- name: foo
+  chart: mychart1
+  installed: false
+  needs:
+  - bar
+`,
+			},
+			detailedExitcode: true,
+			error:            "Identified at least on change",
+			diffs: map[exectest.DiffKey]error{
+				exectest.DiffKey{Name: "bar", Chart: "mychart2", Flags: "--kube-contextdefault--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+				exectest.DiffKey{Name: "foo", Chart: "mychart1", Flags: "--kube-contextdefault--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+			},
+			lists: map[exectest.ListKey]string{
+				exectest.ListKey{Filter: "^foo$", Flags: "--kube-contextdefault--deployed--failed--pending"}: `NAME	REVISION	UPDATED                 	STATUS  	CHART        	APP VERSION	NAMESPACE
+foo 	4       	Fri Nov  1 08:40:07 2019	DEPLOYED	mychart1-3.1.0	3.1.0      	default
+`,
+				exectest.ListKey{Filter: "^bar$", Flags: "--kube-contextdefault--deployed--failed--pending"}: `NAME	REVISION	UPDATED                 	STATUS  	CHART        	APP VERSION	NAMESPACE
+bar 	4       	Fri Nov  1 08:40:07 2019	DEPLOYED	mychart2-3.1.0	3.1.0      	default
+`,
+			},
+			deleted: []exectest.Release{},
+		},
+		{
+			name: "delete foo and bar when bar needs foo",
+			loc:  location(),
+			files: map[string]string{
+				"/path/to/helmfile.yaml": `
+releases:
+- name: bar
+  chart: mychart2
+  installed: false
+  needs:
+  - foo
+- name: foo
+  chart: mychart1
+  installed: false
+`,
+			},
+			detailedExitcode: true,
+			error:            "Identified at least on change",
+			diffs: map[exectest.DiffKey]error{
+				exectest.DiffKey{Name: "bar", Chart: "mychart2", Flags: "--kube-contextdefault--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+				exectest.DiffKey{Name: "foo", Chart: "mychart1", Flags: "--kube-contextdefault--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+			},
+			lists: map[exectest.ListKey]string{
+				exectest.ListKey{Filter: "^foo$", Flags: "--kube-contextdefault--deployed--failed--pending"}: `NAME	REVISION	UPDATED                 	STATUS  	CHART        	APP VERSION	NAMESPACE
+foo 	4       	Fri Nov  1 08:40:07 2019	DEPLOYED	mychart1-3.1.0	3.1.0      	default
+`,
+				exectest.ListKey{Filter: "^bar$", Flags: "--kube-contextdefault--deployed--failed--pending"}: `NAME	REVISION	UPDATED                 	STATUS  	CHART        	APP VERSION	NAMESPACE
+bar 	4       	Fri Nov  1 08:40:07 2019	DEPLOYED	mychart2-3.1.0	3.1.0      	default
+`,
+			},
+			deleted: []exectest.Release{},
+		},
+		//
+		// upgrade and delete: upgrading one while deleting another
+		//
+		{
+			name: "delete foo when foo needs bar",
+			loc:  location(),
+			files: map[string]string{
+				"/path/to/helmfile.yaml": `
+releases:
+- name: bar
+  chart: mychart2
+- name: foo
+  chart: mychart1
+  installed: false
+  needs:
+  - bar
+`,
+			},
+			detailedExitcode: true,
+			error:            "Identified at least on change",
+			diffs: map[exectest.DiffKey]error{
+				exectest.DiffKey{Name: "bar", Chart: "mychart2", Flags: "--kube-contextdefault--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+				exectest.DiffKey{Name: "foo", Chart: "mychart1", Flags: "--kube-contextdefault--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+			},
+			lists: map[exectest.ListKey]string{
+				exectest.ListKey{Filter: "^foo$", Flags: "--kube-contextdefault--deployed--failed--pending"}: `NAME	REVISION	UPDATED                 	STATUS  	CHART        	APP VERSION	NAMESPACE
+foo 	4       	Fri Nov  1 08:40:07 2019	DEPLOYED	mychart1-3.1.0	3.1.0      	default
+`,
+				exectest.ListKey{Filter: "^bar$", Flags: "--kube-contextdefault--deployed--failed--pending"}: `NAME	REVISION	UPDATED                 	STATUS  	CHART        	APP VERSION	NAMESPACE
+bar 	4       	Fri Nov  1 08:40:07 2019	DEPLOYED	mychart2-3.1.0	3.1.0      	default
+`,
+			},
+			upgraded: []exectest.Release{},
+			deleted:  []exectest.Release{},
+		},
+		{
+			name: "delete bar when foo needs bar",
+			loc:  location(),
+			files: map[string]string{
+				"/path/to/helmfile.yaml": `
+releases:
+- name: bar
+  chart: mychart2
+  installed: false
+- name: foo
+  chart: mychart1
+  needs:
+  - bar
+`,
+			},
+			detailedExitcode: true,
+			error:            "Identified at least on change",
+			diffs: map[exectest.DiffKey]error{
+				exectest.DiffKey{Name: "bar", Chart: "mychart2", Flags: "--kube-contextdefault--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+				exectest.DiffKey{Name: "foo", Chart: "mychart1", Flags: "--kube-contextdefault--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+			},
+			lists: map[exectest.ListKey]string{
+				exectest.ListKey{Filter: "^foo$", Flags: "--kube-contextdefault--deployed--failed--pending"}: `NAME	REVISION	UPDATED                 	STATUS  	CHART        	APP VERSION	NAMESPACE
+foo 	4       	Fri Nov  1 08:40:07 2019	DEPLOYED	mychart1-3.1.0	3.1.0      	default
+`,
+				exectest.ListKey{Filter: "^bar$", Flags: "--kube-contextdefault--deployed--failed--pending"}: `NAME	REVISION	UPDATED                 	STATUS  	CHART        	APP VERSION	NAMESPACE
+bar 	4       	Fri Nov  1 08:40:07 2019	DEPLOYED	mychart2-3.1.0	3.1.0      	default
+`,
+			},
+			upgraded: []exectest.Release{},
+			deleted:  []exectest.Release{},
+		},
+		{
+			name: "delete foo when bar needs foo",
+			loc:  location(),
+			files: map[string]string{
+				"/path/to/helmfile.yaml": `
+releases:
+- name: foo
+  chart: mychart1
+  installed: false
+- name: bar
+  chart: mychart2
+  needs:
+  - foo
+`,
+			},
+			detailedExitcode: true,
+			error:            "Identified at least on change",
+			diffs: map[exectest.DiffKey]error{
+				exectest.DiffKey{Name: "bar", Chart: "mychart2", Flags: "--kube-contextdefault--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+				exectest.DiffKey{Name: "foo", Chart: "mychart1", Flags: "--kube-contextdefault--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+			},
+			lists: map[exectest.ListKey]string{
+				exectest.ListKey{Filter: "^foo$", Flags: "--kube-contextdefault--deployed--failed--pending"}: `NAME	REVISION	UPDATED                 	STATUS  	CHART        	APP VERSION	NAMESPACE
+foo 	4       	Fri Nov  1 08:40:07 2019	DEPLOYED	mychart1-3.1.0	3.1.0      	default
+`,
+				exectest.ListKey{Filter: "^bar$", Flags: "--kube-contextdefault--deployed--failed--pending"}: `NAME	REVISION	UPDATED                 	STATUS  	CHART        	APP VERSION	NAMESPACE
+bar 	4       	Fri Nov  1 08:40:07 2019	DEPLOYED	mychart2-3.1.0	3.1.0      	default
+`,
+			},
+			upgraded: []exectest.Release{},
+			deleted:  []exectest.Release{},
+		},
+		{
+			name: "delete bar when bar needs foo",
+			loc:  location(),
+			files: map[string]string{
+				"/path/to/helmfile.yaml": `
+releases:
+- name: foo
+  chart: mychart1
+- name: bar
+  chart: mychart2
+  installed: false
+  needs:
+  - foo
+`,
+			},
+			detailedExitcode: true,
+			error:            "Identified at least on change",
+			diffs: map[exectest.DiffKey]error{
+				exectest.DiffKey{Name: "bar", Chart: "mychart2", Flags: "--kube-contextdefault--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+				exectest.DiffKey{Name: "foo", Chart: "mychart1", Flags: "--kube-contextdefault--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+			},
+			lists: map[exectest.ListKey]string{
+				exectest.ListKey{Filter: "^foo$", Flags: "--kube-contextdefault--deployed--failed--pending"}: `NAME	REVISION	UPDATED                 	STATUS  	CHART        	APP VERSION	NAMESPACE
+foo 	4       	Fri Nov  1 08:40:07 2019	DEPLOYED	mychart1-3.1.0	3.1.0      	default
+`,
+				exectest.ListKey{Filter: "^bar$", Flags: "--kube-contextdefault--deployed--failed--pending"}: `NAME	REVISION	UPDATED                 	STATUS  	CHART        	APP VERSION	NAMESPACE
+bar 	4       	Fri Nov  1 08:40:07 2019	DEPLOYED	mychart2-3.1.0	3.1.0      	default
+`,
+			},
+			upgraded: []exectest.Release{},
+			deleted:  []exectest.Release{},
+		},
+		//
+		// upgrades with selector
+		//
+		{
+			// see https://github.com/roboll/helmfile/issues/919#issuecomment-549831747
+			name: "upgrades with good selector",
+			loc:  location(),
+			files: map[string]string{
+				"/path/to/helmfile.yaml": `
+{{ $mark := "a" }}
+
+releases:
+- name: kubernetes-external-secrets
+  chart: incubator/raw
+  namespace: kube-system
+
+- name: external-secrets
+  chart: incubator/raw
+  namespace: default
+  labels:
+    app: test
+  needs:
+  - kube-system/kubernetes-external-secrets
+
+- name: my-release
+  chart: incubator/raw
+  namespace: default
+  labels:
+    app: test
+  needs:
+  - default/external-secrets
+`,
+			},
+			selectors:        []string{"app=test"},
+			detailedExitcode: true,
+			diffs: map[exectest.DiffKey]error{
+				exectest.DiffKey{Name: "external-secrets", Chart: "incubator/raw", Flags: "--kube-contextdefault--namespacedefault--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+				exectest.DiffKey{Name: "my-release", Chart: "incubator/raw", Flags: "--kube-contextdefault--namespacedefault--detailed-exitcode"}:       helmexec.ExitError{Code: 2},
+			},
+			upgraded: []exectest.Release{},
+			// as we check for log output, set concurrency to 1 to avoid non-deterministic test result
+			concurrency: 1,
+			error:       "Identified at least on change",
+			log: `processing file "helmfile.yaml" in directory "."
+first-pass rendering starting for "helmfile.yaml.part.0": inherited=&{default map[] map[]}, overrode=<nil>
+first-pass uses: &{default map[] map[]}
+first-pass rendering output of "helmfile.yaml.part.0":
+ 0: 
+ 1: 
+ 2: 
+ 3: releases:
+ 4: - name: kubernetes-external-secrets
+ 5:   chart: incubator/raw
+ 6:   namespace: kube-system
+ 7: 
+ 8: - name: external-secrets
+ 9:   chart: incubator/raw
+10:   namespace: default
+11:   labels:
+12:     app: test
+13:   needs:
+14:   - kube-system/kubernetes-external-secrets
+15: 
+16: - name: my-release
+17:   chart: incubator/raw
+18:   namespace: default
+19:   labels:
+20:     app: test
+21:   needs:
+22:   - default/external-secrets
+23: 
+
+first-pass produced: &{default map[] map[]}
+first-pass rendering result of "helmfile.yaml.part.0": {default map[] map[]}
+vals:
+map[]
+defaultVals:[]
+second-pass rendering result of "helmfile.yaml.part.0":
+ 0: 
+ 1: 
+ 2: 
+ 3: releases:
+ 4: - name: kubernetes-external-secrets
+ 5:   chart: incubator/raw
+ 6:   namespace: kube-system
+ 7: 
+ 8: - name: external-secrets
+ 9:   chart: incubator/raw
+10:   namespace: default
+11:   labels:
+12:     app: test
+13:   needs:
+14:   - kube-system/kubernetes-external-secrets
+15: 
+16: - name: my-release
+17:   chart: incubator/raw
+18:   namespace: default
+19:   labels:
+20:     app: test
+21:   needs:
+22:   - default/external-secrets
+23: 
+
+merged environment: &{default map[] map[]}
+2 release(s) matching app=test found in helmfile.yaml
+
+worker 1/1 started
+worker 1/1 finished
+worker 1/1 started
+worker 1/1 finished
+Affected releases are:
+  external-secrets (incubator/raw) UPDATED
+  my-release (incubator/raw) UPDATED
+
+`,
+		},
+		{
+			// see https://github.com/roboll/helmfile/issues/919#issuecomment-549831747
+			name: "upgrades with bad selector",
+			loc:  location(),
+			files: map[string]string{
+				"/path/to/helmfile.yaml": `
+{{ $mark := "a" }}
+
+releases:
+- name: kubernetes-external-secrets
+  chart: incubator/raw
+  namespace: kube-system
+
+- name: external-secrets
+  chart: incubator/raw
+  namespace: default
+  labels:
+    app: test
+  needs:
+  - kube-system/kubernetes-external-secrets
+
+- name: my-release
+  chart: incubator/raw
+  namespace: default
+  labels:
+    app: test
+  needs:
+  - default/external-secrets
+`,
+			},
+			selectors:        []string{"app=test_non_existent"},
+			detailedExitcode: true,
+			diffs:            map[exectest.DiffKey]error{},
+			upgraded:         []exectest.Release{},
+			error:            "err: no releases found that matches specified selector(app=test_non_existent) and environment(default), in any helmfile",
+			// as we check for log output, set concurrency to 1 to avoid non-deterministic test result
+			concurrency: 1,
+			log: `processing file "helmfile.yaml" in directory "."
+first-pass rendering starting for "helmfile.yaml.part.0": inherited=&{default map[] map[]}, overrode=<nil>
+first-pass uses: &{default map[] map[]}
+first-pass rendering output of "helmfile.yaml.part.0":
+ 0: 
+ 1: 
+ 2: 
+ 3: releases:
+ 4: - name: kubernetes-external-secrets
+ 5:   chart: incubator/raw
+ 6:   namespace: kube-system
+ 7: 
+ 8: - name: external-secrets
+ 9:   chart: incubator/raw
+10:   namespace: default
+11:   labels:
+12:     app: test
+13:   needs:
+14:   - kube-system/kubernetes-external-secrets
+15: 
+16: - name: my-release
+17:   chart: incubator/raw
+18:   namespace: default
+19:   labels:
+20:     app: test
+21:   needs:
+22:   - default/external-secrets
+23: 
+
+first-pass produced: &{default map[] map[]}
+first-pass rendering result of "helmfile.yaml.part.0": {default map[] map[]}
+vals:
+map[]
+defaultVals:[]
+second-pass rendering result of "helmfile.yaml.part.0":
+ 0: 
+ 1: 
+ 2: 
+ 3: releases:
+ 4: - name: kubernetes-external-secrets
+ 5:   chart: incubator/raw
+ 6:   namespace: kube-system
+ 7: 
+ 8: - name: external-secrets
+ 9:   chart: incubator/raw
+10:   namespace: default
+11:   labels:
+12:     app: test
+13:   needs:
+14:   - kube-system/kubernetes-external-secrets
+15: 
+16: - name: my-release
+17:   chart: incubator/raw
+18:   namespace: default
+19:   labels:
+20:     app: test
+21:   needs:
+22:   - default/external-secrets
+23: 
+
+merged environment: &{default map[] map[]}
+0 release(s) matching app=test_non_existent found in helmfile.yaml
+
+`,
+		},
+		//
+		// error cases
+		//
+		{
+			name: "non-existent release in needs",
+			loc:  location(),
+			files: map[string]string{
+				"/path/to/helmfile.yaml": `
+releases:
+- name: baz
+  namespace: ns1
+  chart: mychart3
+- name: foo
+  chart: mychart1
+  needs:
+  - bar
+`,
+			},
+			detailedExitcode: true,
+			diffs: map[exectest.DiffKey]error{
+				exectest.DiffKey{Name: "baz", Chart: "mychart3", Flags: "--kube-contextdefault--namespacens1--detailed-exitcode"}: helmexec.ExitError{Code: 2},
+				exectest.DiffKey{Name: "foo", Chart: "mychart1", Flags: "--kube-contextdefault--detailed-exitcode"}:               helmexec.ExitError{Code: 2},
+			},
+			lists:       map[exectest.ListKey]string{},
+			upgraded:    []exectest.Release{},
+			deleted:     []exectest.Release{},
+			concurrency: 1,
+			error:       `in ./helmfile.yaml: "foo" depends on nonexistent release "bar"`,
+			log: `processing file "helmfile.yaml" in directory "."
+first-pass rendering starting for "helmfile.yaml.part.0": inherited=&{default map[] map[]}, overrode=<nil>
+first-pass uses: &{default map[] map[]}
+first-pass rendering output of "helmfile.yaml.part.0":
+ 0: 
+ 1: releases:
+ 2: - name: baz
+ 3:   namespace: ns1
+ 4:   chart: mychart3
+ 5: - name: foo
+ 6:   chart: mychart1
+ 7:   needs:
+ 8:   - bar
+ 9: 
+
+first-pass produced: &{default map[] map[]}
+first-pass rendering result of "helmfile.yaml.part.0": {default map[] map[]}
+vals:
+map[]
+defaultVals:[]
+second-pass rendering result of "helmfile.yaml.part.0":
+ 0: 
+ 1: releases:
+ 2: - name: baz
+ 3:   namespace: ns1
+ 4:   chart: mychart3
+ 5: - name: foo
+ 6:   chart: mychart1
+ 7:   needs:
+ 8:   - bar
+ 9: 
+
+merged environment: &{default map[] map[]}
+2 release(s) found in helmfile.yaml
+
+err: "foo" depends on nonexistent release "bar"
+`,
+		},
+	}
+
+	for i := range testcases {
+		tc := testcases[i]
+		t.Run(tc.name, func(t *testing.T) {
+			wantUpgrades := tc.upgraded
+			wantDeletes := tc.deleted
+
+			var helm = &exectest.Helm{
+				FailOnUnexpectedList: true,
+				FailOnUnexpectedDiff: true,
+				Lists:                tc.lists,
+				Diffs:                tc.diffs,
+				DiffMutex:            &sync.Mutex{},
+				ChartsMutex:          &sync.Mutex{},
+				ReleasesMutex:        &sync.Mutex{},
+			}
+
+			bs := &bytes.Buffer{}
+
+			func() {
+				logReader, logWriter := io.Pipe()
+
+				logFlushed := &sync.WaitGroup{}
+				// Ensure all the log is consumed into `bs` by calling `logWriter.Close()` followed by `logFlushed.Wait()`
+				logFlushed.Add(1)
+				go func() {
+					scanner := bufio.NewScanner(logReader)
+					for scanner.Scan() {
+						bs.Write(scanner.Bytes())
+						bs.WriteString("\n")
+					}
+					logFlushed.Done()
+				}()
+
+				defer func() {
+					// This is here to avoid data-trace on bytes buffer `bs` to capture logs
+					if err := logWriter.Close(); err != nil {
+						panic(err)
+					}
+					logFlushed.Wait()
+				}()
+
+				logger := helmexec.NewLogger(logWriter, "debug")
+
+				valsRuntime, err := vals.New(vals.Options{CacheSize: 32})
+				if err != nil {
+					t.Errorf("unexpected error creating vals runtime: %v", err)
+				}
+
+				app := appWithFs(&App{
+					OverrideHelmBinary:  DefaultHelmBinary,
+					glob:                filepath.Glob,
+					abs:                 filepath.Abs,
+					OverrideKubeContext: "default",
+					Env:                 "default",
+					Logger:              logger,
+					helms: map[helmKey]helmexec.Interface{
+						createHelmKey("helm", "default"): helm,
+					},
+					valsRuntime: valsRuntime,
+				}, tc.files)
+
+				if tc.ns != "" {
+					app.Namespace = tc.ns
+				}
+
+				if tc.selectors != nil {
+					app.Selectors = tc.selectors
+				}
+
+				diffErr := app.Diff(diffConfig{
+					// if we check log output, concurrency must be 1. otherwise the test becomes non-deterministic.
+					concurrency:      tc.concurrency,
+					logger:           logger,
+					detailedExitcode: tc.detailedExitcode,
+				})
+				if tc.error == "" && diffErr != nil {
+					t.Fatalf("unexpected error for data defined at %s: %v", tc.loc, diffErr)
+				} else if tc.error != "" && diffErr == nil {
+					t.Fatalf("expected error did not occur for data defined at %s", tc.loc)
+				} else if tc.error != "" && diffErr != nil && tc.error != diffErr.Error() {
+					t.Fatalf("invalid error: expected %q, got %q", tc.error, diffErr.Error())
+				}
+
+				if len(wantUpgrades) > len(helm.Releases) {
+					t.Fatalf("insufficient number of upgrades: got %d, want %d", len(helm.Releases), len(wantUpgrades))
+				}
+
+				for relIdx := range wantUpgrades {
+					if wantUpgrades[relIdx].Name != helm.Releases[relIdx].Name {
+						t.Errorf("releases[%d].name: got %q, want %q", relIdx, helm.Releases[relIdx].Name, wantUpgrades[relIdx].Name)
+					}
+					for flagIdx := range wantUpgrades[relIdx].Flags {
+						if wantUpgrades[relIdx].Flags[flagIdx] != helm.Releases[relIdx].Flags[flagIdx] {
+							t.Errorf("releaes[%d].flags[%d]: got %v, want %v", relIdx, flagIdx, helm.Releases[relIdx].Flags[flagIdx], wantUpgrades[relIdx].Flags[flagIdx])
+						}
+					}
+				}
+
+				if len(wantDeletes) > len(helm.Deleted) {
+					t.Fatalf("insufficient number of deletes: got %d, want %d", len(helm.Deleted), len(wantDeletes))
+				}
+
+				for relIdx := range wantDeletes {
+					if wantDeletes[relIdx].Name != helm.Deleted[relIdx].Name {
+						t.Errorf("releases[%d].name: got %q, want %q", relIdx, helm.Deleted[relIdx].Name, wantDeletes[relIdx].Name)
+					}
+					for flagIdx := range wantDeletes[relIdx].Flags {
+						if wantDeletes[relIdx].Flags[flagIdx] != helm.Deleted[relIdx].Flags[flagIdx] {
+							t.Errorf("releaes[%d].flags[%d]: got %v, want %v", relIdx, flagIdx, helm.Deleted[relIdx].Flags[flagIdx], wantDeletes[relIdx].Flags[flagIdx])
+						}
+					}
+				}
+			}()
+
+			if tc.log != "" {
+				actual := bs.String()
+
+				diff, exists := testhelper.Diff(tc.log, actual, 3)
+				if exists {
+					t.Errorf("unexpected log for data defined %s:\nDIFF\n%s\nEOD", tc.loc, diff)
+				}
+			}
+		})
+	}
+}
diff --git a/pkg/tmpl/context_funcs_test.go b/pkg/tmpl/context_funcs_test.go
index 373d6eb..3483ae8 100644
--- a/pkg/tmpl/context_funcs_test.go
+++ b/pkg/tmpl/context_funcs_test.go
@@ -133,3 +133,47 @@ func TestTpl(t *testing.T) {
 		t.Errorf("unexpected result: expected=%v, actual=%v", expected, actual)
 	}
 }
+
+func TestRequired(t *testing.T) {
+	type args struct {
+		warn string
+		val  interface{}
+	}
+	tests := []struct {
+		name    string
+		args    args
+		want    interface{}
+		wantErr bool
+	}{
+		{
+			name:    "required val is nil",
+			args:    args{warn: "This value is required", val: nil},
+			want:    nil,
+			wantErr: true,
+		},
+		{
+			name:    "required val is empty string",
+			args:    args{warn: "This value is required", val: ""},
+			want:    nil,
+			wantErr: true,
+		},
+		{
+			name:    "required val is existed",
+			args:    args{warn: "This value is required", val: "foo"},
+			want:    "foo",
+			wantErr: false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := Required(tt.args.warn, tt.args.val)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("Required() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("Required() got = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
diff --git a/pkg/tmpl/context_tmpl_test.go b/pkg/tmpl/context_tmpl_test.go
index b30b8cc..af14824 100644
--- a/pkg/tmpl/context_tmpl_test.go
+++ b/pkg/tmpl/context_tmpl_test.go
@@ -250,3 +250,81 @@ func Test_renderTemplateToString(t *testing.T) {
 		})
 	}
 }
+
+func TestRenderTemplate_Required(t *testing.T) {
+	tests := []struct {
+		name    string
+		s       string
+		data    map[string]interface{}
+		want    string
+		wantErr bool
+	}{
+		{
+			name: ".foo is existed",
+			s:    `{{ required ".foo.bar is required" .foo }}`,
+			data: map[string]interface{}{
+				"foo": "bar",
+			},
+			want:    "bar",
+			wantErr: false,
+		},
+		{
+			name: ".foo.bar is existed",
+			s:    `{{ required "foo.bar is required" .foo.bar }}`,
+			data: map[string]interface{}{
+				"foo": map[string]interface{}{
+					"bar": "FOO_BAR",
+				},
+			},
+			want:    "FOO_BAR",
+			wantErr: false,
+		},
+		{
+			name: ".foo.bar is existed but value is nil",
+			s:    `{{ required "foo.bar is required" .foo.bar }}`,
+			data: map[string]interface{}{
+				"foo": map[string]interface{}{
+					"bar": nil,
+				},
+			},
+			wantErr: true,
+		},
+		{
+			name: ".foo.bar is existed but value is empty string",
+			s:    `{{ required "foo.bar is required" .foo.bar }}`,
+			data: map[string]interface{}{
+				"foo": map[string]interface{}{
+					"bar": "",
+				},
+			},
+			wantErr: true,
+		},
+		{
+			name: ".foo is nil",
+			s:    `{{ required "foo is required" .foo }}`,
+			data: map[string]interface{}{
+				"foo": nil,
+			},
+			wantErr: true,
+		},
+		{
+			name: ".foo is a empty string",
+			s:    `{{ required "foo is required" .foo }}`,
+			data: map[string]interface{}{
+				"foo": "",
+			},
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		got, err := renderTemplateToString(tt.s, tt.data)
+		if (err != nil) != tt.wantErr {
+			t.Errorf("renderTemplateToString() for %s error = %v, wantErr %v", tt.name, err, tt.wantErr)
+			return
+		}
+		if got != tt.want {
+			t.Errorf("renderTemplateToString() for %s = %v, want %v", tt.name, got, tt.want)
+		}
+	}
+}