Repository: roboll/helmfile
Pull request #1296 (2020-06-04 20:35:57)

Add the ability to load a remote environment values file
This PR enables the user to specify a remote path for an environment values file, e.g.,

```yaml
environments:
  cluster-azure-us-west:
    values:
      - git::https://git.company.org/helmfiles/global/azure.yaml?ref=master
      - git::https://git.company.org/helmfiles/global/us-west.yaml?ref=master
  cluster-gcp-europe-west:
    values:
      - git::https://git.company.org/helmfiles/global/gcp.yaml?ref=master
      - git::https://git.company.org/helmfiles/global/europe-west.yaml?ref=master

releases:
  - ...
```

This is particularly useful when you co-locate helmfiles within your project repo but want to reuse the definitions in a global repo.

Please let me know what y'all think.  Thanks.


Patch:
diff --git a/README.md b/README.md
index ba56b3f..47c9ab1 100644
--- a/README.md
+++ b/README.md
@@ -532,6 +532,7 @@ In addition to built-in ones, the following custom template functions are availa
 - `fromYaml` reads a golang string and generates a map
 - `setValueAtPath PATH NEW_VALUE` traverses a golang map, replaces the value at the PATH with NEW_VALUE
 - `toYaml` marshals a map into a string
+- `get` returns the value of the specified key if present in the `.Values` object, otherwise will return the default value defined in the function
 
 ### Values Files Templates
 
@@ -671,7 +672,7 @@ releaseName: prod
 `values.yaml.gotmpl`
 
 ```yaml
-domain: {{ .Values | getOrNil "my.domain" | default "dev.example.com" }}
+domain: {{ .Values | get "my.domain" "dev.example.com" }}
 ```
 
 `helmfile sync` installs `myapp` with the value `domain=dev.example.com`,
diff --git a/docs/writing-helmfile.md b/docs/writing-helmfile.md
index 792785e..4846bd9 100644
--- a/docs/writing-helmfile.md
+++ b/docs/writing-helmfile.md
@@ -14,10 +14,10 @@ That is, the following example let `helmfile` fail when you have no `eventApi.re
 {{ .Values.eventApi.replicas | default 1 }}
 ```
 
-In case it isn't a mistake and you do want to allow missing keys, use the `getOrNil` template function:
+In case it isn't a mistake and you do want to allow missing keys, use the `get` template function:
 
 ```
-{{ .Values | getOrNil "eventApi.replicas" }}
+{{ .Values | get "eventApi.replicas" nil }}
 ```
 
 This result in printing `<no value` in your template, that may or may not result in a failure.
@@ -25,7 +25,7 @@ This result in printing `<no value` in your template, that may or may not result
 If you want a kind of default values that is used when a missing key was referenced, use `default` like:
 
 ```
-{{ .Values | getOrNil "eventApi.replicas" | default 1 }}
+{{ .Values | get "eventApi.replicas" 1 }}
 ```
 
 Now, you get `1` when there is no `eventApi.replicas` defined in environment values.
diff --git a/go.sum b/go.sum
index 1768b18..36bbcdb 100644
--- a/go.sum
+++ b/go.sum
@@ -650,8 +650,10 @@ github.com/ory/dockertest v3.3.4+incompatible/go.mod h1:1vX4m9wsvi00u5bseYwXaSnh
 github.com/otiai10/copy v1.1.1 h1:PH7IFlRQ6Fv9vYmuXbDRLdgTHoP1w483kPNUP2bskpo=
 github.com/otiai10/copy v1.1.1/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw=
 github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE=
+github.com/otiai10/curr v1.0.0 h1:TJIWdbX0B+kpNagQrjgq8bCMrbhiuX73M2XwgtDMoOI=
 github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
 github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
+github.com/otiai10/mint v1.3.1 h1:BCmzIS3n71sGfHB5NMNDB3lHYPz8fWSkCAErHed//qc=
 github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
 github.com/oxtoacart/bpool v0.0.0-20150712133111-4e1c5567d7c2 h1:CXwSGu/LYmbjEab5aMCs5usQRVBGThelUKBNnoSOuso=
 github.com/oxtoacart/bpool v0.0.0-20150712133111-4e1c5567d7c2/go.mod h1:L3UMQOThbttwfYRNFOWLLVXMhk5Lkio4GGOtw5UrxS0=
@@ -1050,6 +1052,7 @@ google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/
 google.golang.org/api v0.20.0 h1:jz2KixHX7EcCPiQrySzPdnYT7DbINAypCqKZ1Z7GM40=
 google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
 google.golang.org/api v0.21.0 h1:zS+Q/CJJnVlXpXQVIz+lH0ZT2lBuT2ac7XD8Y/3w6hY=
+google.golang.org/api v0.26.0 h1:VJZ8h6E8ip82FRpQl848c5vAadxlTXrUh8RzQzSRm08=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
diff --git a/pkg/app/app.go b/pkg/app/app.go
index 294379e..5e9534b 100644
--- a/pkg/app/app.go
+++ b/pkg/app/app.go
@@ -193,7 +193,7 @@ func (a *App) Diff(c DiffConfigProvider) error {
 		// See https://github.com/roboll/helmfile/issues/749
 		code := 2
 		e := &Error{
-			msg:  "Identified at least on change",
+			msg:  "Identified at least one change",
 			code: &code,
 		}
 		return e
@@ -487,6 +487,7 @@ func (a *App) loadDesiredStateFromYaml(file string, opts ...LoadOpts) (*state.He
 		namespace:  a.Namespace,
 		logger:     a.Logger,
 		abs:        a.abs,
+		remote:     a.remote,
 
 		overrideKubeContext: a.OverrideKubeContext,
 		overrideHelmBinary:  a.OverrideHelmBinary,
@@ -791,18 +792,7 @@ func (a *App) visitStatesWithSelectorsAndRemoteSupport(fileOrDir string, converg
 		return err
 	}
 
-	getter := &remote.GoGetter{Logger: a.Logger}
-
-	remote := &remote.Remote{
-		Logger:     a.Logger,
-		Home:       dir,
-		Getter:     getter,
-		ReadFile:   a.readFile,
-		DirExists:  a.directoryExistsAt,
-		FileExists: a.fileExistsAt,
-	}
-
-	a.remote = remote
+	a.remote = remote.NewRemote(a.Logger, dir, a.readFile, a.directoryExistsAt, a.fileExistsAt)
 
 	return a.visitStates(fileOrDir, opts, converge)
 }
diff --git a/pkg/app/desired_state_file_loader.go b/pkg/app/desired_state_file_loader.go
index 3fee0cb..5c76b7e 100644
--- a/pkg/app/desired_state_file_loader.go
+++ b/pkg/app/desired_state_file_loader.go
@@ -5,6 +5,7 @@ import (
 	"errors"
 	"fmt"
 	"github.com/roboll/helmfile/pkg/helmexec"
+	"github.com/roboll/helmfile/pkg/remote"
 	"path/filepath"
 	"sort"
 
@@ -32,6 +33,7 @@ type desiredStateLoader struct {
 	glob       func(string) ([]string, error)
 	getHelm    func(*state.HelmState) helmexec.Interface
 
+	remote      *remote.Remote
 	logger      *zap.SugaredLogger
 	valsRuntime vals.Evaluator
 }
@@ -46,7 +48,7 @@ func (ld *desiredStateLoader) Load(f string, opts LoadOpts) (*state.HelmState, e
 			return nil, fmt.Errorf("bug: opts.CalleePath was nil: f=%s, opts=%v", f, opts)
 		}
 		storage := state.NewStorage(opts.CalleePath, ld.logger, ld.glob)
-		envld := state.NewEnvironmentValuesLoader(storage, ld.readFile, ld.logger)
+		envld := state.NewEnvironmentValuesLoader(storage, ld.readFile, ld.logger, ld.remote)
 		handler := state.MissingFileHandlerError
 		vals, err := envld.LoadEnvironmentValues(&handler, args)
 		if err != nil {
@@ -147,7 +149,7 @@ func (ld *desiredStateLoader) loadFileWithOverrides(inheritedEnv, overrodeEnv *e
 }
 
 func (a *desiredStateLoader) underlying() *state.StateCreator {
-	c := state.NewCreator(a.logger, a.readFile, a.fileExists, a.abs, a.glob, a.valsRuntime, a.getHelm, a.overrideHelmBinary)
+	c := state.NewCreator(a.logger, a.readFile, a.fileExists, a.abs, a.glob, a.valsRuntime, a.getHelm, a.overrideHelmBinary, a.remote)
 	c.LoadFile = a.loadFile
 	return c
 }
diff --git a/pkg/helmexec/exit_error.go b/pkg/helmexec/exit_error.go
index 9419cf4..31eaf7c 100644
--- a/pkg/helmexec/exit_error.go
+++ b/pkg/helmexec/exit_error.go
@@ -5,19 +5,55 @@ import (
 	"strings"
 )
 
-func newExitError(helmCmdPath string, exitStatus int, errorMessage string) ExitError {
+func newExitError(path string, args []string, exitStatus int, err error, stderr, combined string) ExitError {
+	var out string
+
+	out += fmt.Sprintf("PATH:\n%s", Indent(path, "  "))
+
+	out += "\n\nARGS:"
+	for i, a := range args {
+		out += fmt.Sprintf("\n%s", Indent(fmt.Sprintf("%d: %s (%d bytes)", i, a, len(a)), "  "))
+	}
+
+	out += fmt.Sprintf("\n\nERROR:\n%s", Indent(err.Error(), "  "))
+
+	out += fmt.Sprintf("\n\nEXIT STATUS\n%s", Indent(fmt.Sprintf("%d", exitStatus), "  "))
+
+	if len(stderr) > 0 {
+		out += fmt.Sprintf("\n\nSTDERR:\n%s", Indent(stderr, "  "))
+	}
+
+	if len(combined) > 0 {
+		out += fmt.Sprintf("\n\nCOMBINED OUTPUT:\n%s", Indent(combined, "  "))
+	}
+
 	return ExitError{
-		Message: fmt.Sprintf("the following cmd exited with status %d:\n%s\n\n%s", exitStatus, indent(strings.TrimSpace(helmCmdPath)), indent(strings.TrimSpace(errorMessage))),
+		Message: fmt.Sprintf("command %q exited with non-zero status:\n\n%s", path, out),
 		Code:    exitStatus,
 	}
 }
 
-func indent(text string) string {
+// indents a block of text with an indent string
+func Indent(text, indent string) string {
+	var b strings.Builder
+
+	b.Grow(len(text) * 2)
+
 	lines := strings.Split(text, "\n")
-	for i := range lines {
-		lines[i] = "  " + lines[i]
+
+	last := len(lines) - 1
+
+	for i, j := range lines {
+		if i > 0 && i < last && j != "" {
+			b.WriteString("\n")
+		}
+
+		if j != "" {
+			b.WriteString(indent + j)
+		}
 	}
-	return strings.Join(lines, "\n")
+
+	return b.String()
 }
 
 // ExitError is created whenever your shell command exits with a non-zero exit status
diff --git a/pkg/helmexec/runner.go b/pkg/helmexec/runner.go
index 179c019..8762c11 100644
--- a/pkg/helmexec/runner.go
+++ b/pkg/helmexec/runner.go
@@ -5,6 +5,7 @@ import (
 	"errors"
 	"fmt"
 	"go.uber.org/zap"
+	"io"
 	"os"
 	"os/exec"
 	"strings"
@@ -33,10 +34,10 @@ func (shell ShellRunner) Execute(cmd string, args []string, env map[string]strin
 	preparedCmd := exec.Command(cmd, args...)
 	preparedCmd.Dir = shell.Dir
 	preparedCmd.Env = mergeEnv(os.Environ(), env)
-	return combinedOutput(preparedCmd, shell.Logger)
+	return Output(preparedCmd)
 }
 
-func combinedOutput(c *exec.Cmd, logger *zap.SugaredLogger) ([]byte, error) {
+func Output(c *exec.Cmd) ([]byte, error) {
 	if c.Stdout != nil {
 		return nil, errors.New("exec: Stdout already set")
 	}
@@ -45,13 +46,11 @@ func combinedOutput(c *exec.Cmd, logger *zap.SugaredLogger) ([]byte, error) {
 	}
 	var stdout bytes.Buffer
 	var stderr bytes.Buffer
-	c.Stdout = &stdout
-	c.Stderr = &stderr
+	var combined bytes.Buffer
+	c.Stdout = io.MultiWriter(&stdout, &combined)
+	c.Stderr = io.MultiWriter(&stderr, &combined)
 	err := c.Run()
 
-	o := stdout.Bytes()
-	e := stderr.Bytes()
-
 	if err != nil {
 		// TrimSpace is necessary, because otherwise helmfile prints the redundant new-lines after each error like:
 		//
@@ -64,14 +63,13 @@ func combinedOutput(c *exec.Cmd, logger *zap.SugaredLogger) ([]byte, error) {
 			// so that helmfile could return its own exit code accordingly
 			waitStatus := ee.Sys().(syscall.WaitStatus)
 			exitStatus := waitStatus.ExitStatus()
-			cmd := fmt.Sprintf("%s %s", c.Path, strings.Join(c.Args, " "))
-			err = newExitError(cmd, exitStatus, string(e))
+			err = newExitError(c.Path, c.Args, exitStatus, ee, stderr.String(), combined.String())
 		default:
 			panic(fmt.Sprintf("unexpected error: %v", err))
 		}
 	}
 
-	return o, err
+	return stdout.Bytes(), err
 }
 
 func mergeEnv(orig []string, new map[string]string) []string {
diff --git a/pkg/remote/remote.go b/pkg/remote/remote.go
index ba4f118..e3e9179 100644
--- a/pkg/remote/remote.go
+++ b/pkg/remote/remote.go
@@ -260,3 +260,15 @@ func (g *GoGetter) Get(wd, src, dst string) error {
 
 	return nil
 }
+
+func NewRemote(logger *zap.SugaredLogger, homeDir string, readFile func(string) ([]byte, error), dirExists func(string) bool, fileExists func(string) bool) *Remote {
+	remote := &Remote{
+		Logger:     logger,
+		Home:       homeDir,
+		Getter:     &GoGetter{Logger: logger},
+		ReadFile:   readFile,
+		DirExists:  dirExists,
+		FileExists: fileExists,
+	}
+	return remote
+}
diff --git a/pkg/state/create.go b/pkg/state/create.go
index 67efa63..b30a810 100644
--- a/pkg/state/create.go
+++ b/pkg/state/create.go
@@ -5,6 +5,7 @@ import (
 	"errors"
 	"fmt"
 	"github.com/roboll/helmfile/pkg/helmexec"
+	"github.com/roboll/helmfile/pkg/remote"
 	"io"
 	"os"
 
@@ -52,9 +53,11 @@ type StateCreator struct {
 	getHelm func(*HelmState) helmexec.Interface
 
 	overrideHelmBinary string
+
+	remote *remote.Remote
 }
 
-func NewCreator(logger *zap.SugaredLogger, readFile func(string) ([]byte, error), fileExists func(string) (bool, error), abs func(string) (string, error), glob func(string) ([]string, error), valsRuntime vals.Evaluator, getHelm func(*HelmState) helmexec.Interface, overrideHelmBinary string) *StateCreator {
+func NewCreator(logger *zap.SugaredLogger, readFile func(string) ([]byte, error), fileExists func(string) (bool, error), abs func(string) (string, error), glob func(string) ([]string, error), valsRuntime vals.Evaluator, getHelm func(*HelmState) helmexec.Interface, overrideHelmBinary string, remote *remote.Remote) *StateCreator {
 	return &StateCreator{
 		logger:      logger,
 		readFile:    readFile,
@@ -66,6 +69,8 @@ func NewCreator(logger *zap.SugaredLogger, readFile func(string) ([]byte, error)
 		getHelm:     getHelm,
 
 		overrideHelmBinary: overrideHelmBinary,
+
+		remote: remote,
 	}
 }
 
@@ -139,7 +144,7 @@ func (c *StateCreator) LoadEnvValues(target *HelmState, env string, ctxEnv *envi
 		return nil, &StateLoadError{fmt.Sprintf("failed to read %s", state.FilePath), err}
 	}
 
-	e.Defaults, err = state.loadValuesEntries(nil, state.DefaultValues)
+	e.Defaults, err = state.loadValuesEntries(nil, state.DefaultValues, c.remote)
 	if err != nil {
 		return nil, err
 	}
@@ -203,7 +208,7 @@ func (c *StateCreator) loadEnvValues(st *HelmState, name string, failOnMissingEn
 	envSpec, ok := st.Environments[name]
 	if ok {
 		var err error
-		envVals, err = st.loadValuesEntries(envSpec.MissingFileHandler, envSpec.Values)
+		envVals, err = st.loadValuesEntries(envSpec.MissingFileHandler, envSpec.Values, c.remote)
 		if err != nil {
 			return nil, err
 		}
@@ -321,11 +326,11 @@ func (c *StateCreator) scatterGatherEnvSecretFiles(st *HelmState, envSecretFiles
 	return nil
 }
 
-func (st *HelmState) loadValuesEntries(missingFileHandler *string, entries []interface{}) (map[string]interface{}, error) {
+func (st *HelmState) loadValuesEntries(missingFileHandler *string, entries []interface{}, remote *remote.Remote) (map[string]interface{}, error) {
 	envVals := map[string]interface{}{}
 
 	valuesEntries := append([]interface{}{}, entries...)
-	ld := NewEnvironmentValuesLoader(st.storage(), st.readFile, st.logger)
+	ld := NewEnvironmentValuesLoader(st.storage(), st.readFile, st.logger, remote)
 	var err error
 	envVals, err = ld.LoadEnvironmentValues(missingFileHandler, valuesEntries)
 	if err != nil {
diff --git a/pkg/state/envvals_loader.go b/pkg/state/envvals_loader.go
index c5ea8ef..53d605a 100644
--- a/pkg/state/envvals_loader.go
+++ b/pkg/state/envvals_loader.go
@@ -5,6 +5,7 @@ import (
 	"github.com/imdario/mergo"
 	"github.com/roboll/helmfile/pkg/environment"
 	"github.com/roboll/helmfile/pkg/maputil"
+	"github.com/roboll/helmfile/pkg/remote"
 	"github.com/roboll/helmfile/pkg/tmpl"
 	"go.uber.org/zap"
 	"gopkg.in/yaml.v2"
@@ -17,13 +18,16 @@ type EnvironmentValuesLoader struct {
 	readFile func(string) ([]byte, error)
 
 	logger *zap.SugaredLogger
+
+	remote *remote.Remote
 }
 
-func NewEnvironmentValuesLoader(storage *Storage, readFile func(string) ([]byte, error), logger *zap.SugaredLogger) *EnvironmentValuesLoader {
+func NewEnvironmentValuesLoader(storage *Storage, readFile func(string) ([]byte, error), logger *zap.SugaredLogger, remote *remote.Remote) *EnvironmentValuesLoader {
 	return &EnvironmentValuesLoader{
 		storage:  storage,
 		readFile: readFile,
 		logger:   logger,
+		remote:   remote,
 	}
 }
 
@@ -36,6 +40,11 @@ func (ld *EnvironmentValuesLoader) LoadEnvironmentValues(missingFileHandler *str
 		switch strOrMap := entry.(type) {
 		case string:
 			urlOrPath := strOrMap
+			localPath, err := ld.remote.Locate(urlOrPath)
+			if err == nil {
+				urlOrPath = localPath
+			}
+
 			files, skipped, err := ld.storage.resolveFile(missingFileHandler, "environment values", urlOrPath)
 			if err != nil {
 				return nil, err
diff --git a/pkg/state/helmx.go b/pkg/state/helmx.go
index fe4b36b..8c45209 100644
--- a/pkg/state/helmx.go
+++ b/pkg/state/helmx.go
@@ -33,6 +33,8 @@ func (st *HelmState) PrepareChartify(helm helmexec.Interface, release *ReleaseSp
 
 	opts.ChartVersion = release.Version
 
+	opts.Namespace = release.Namespace
+
 	dir := filepath.Join(st.basePath, release.Chart)
 	if stat, _ := os.Stat(dir); stat != nil && stat.IsDir() {
 		if exists, err := st.fileExists(filepath.Join(dir, "Chart.yaml")); err == nil && !exists {
diff --git a/pkg/tmpl/context_funcs.go b/pkg/tmpl/context_funcs.go
index fac7c8c..5d65e58 100644
--- a/pkg/tmpl/context_funcs.go
+++ b/pkg/tmpl/context_funcs.go
@@ -2,6 +2,7 @@ package tmpl
 
 import (
 	"fmt"
+	"github.com/roboll/helmfile/pkg/helmexec"
 	"golang.org/x/sync/errgroup"
 	"gopkg.in/yaml.v2"
 	"io"
@@ -97,25 +98,9 @@ func (c *Context) Exec(command string, args []interface{}, inputs ...string) (st
 	g.Go(func() error {
 		// We use CombinedOutput to produce helpful error messages
 		// See https://github.com/roboll/helmfile/issues/1158
-		bs, err := cmd.CombinedOutput()
+		bs, err := helmexec.Output(cmd)
 		if err != nil {
-			args := strings.Join(strArgs, ", ")
-			shownCmd := []string{command}
-			if len(args) > 0 {
-				shownCmd = append(shownCmd, args)
-			}
-
-			var out string
-
-			out += fmt.Sprintf("\n\nCOMMAND:\n%s", Indent(strings.Join(shownCmd, " "), "  "))
-
-			out += fmt.Sprintf("\n\nERROR:\n%s", Indent(err.Error(), "  "))
-
-			if len(bs) > 0 {
-				out += fmt.Sprintf("\n\nCOMBINED OUTPUT:\n%s", Indent(string(bs), "  "))
-			}
-
-			return fmt.Errorf("%v%s", err, out)
+			return err
 		}
 
 		bytes = bs
@@ -130,29 +115,6 @@ func (c *Context) Exec(command string, args []interface{}, inputs ...string) (st
 	return string(bytes), nil
 }
 
-// indents a block of text with an indent string
-func Indent(text, indent string) string {
-	var b strings.Builder
-
-	b.Grow(len(text) * 2)
-
-	lines := strings.Split(text, "\n")
-
-	last := len(lines) - 1
-
-	for i, j := range lines {
-		if i > 0 && i < last && j != "" {
-			b.WriteString("\n")
-		}
-
-		if j != "" {
-			b.WriteString(indent + j)
-		}
-	}
-
-	return b.String()
-}
-
 func (c *Context) ReadFile(filename string) (string, error) {
 	var path string
 	if filepath.IsAbs(filename) {


Test patch:
diff --git a/pkg/app/app_test.go b/pkg/app/app_test.go
index 4cb4614..884e7bc 100644
--- a/pkg/app/app_test.go
+++ b/pkg/app/app_test.go
@@ -4,6 +4,7 @@ import (
 	"bufio"
 	"bytes"
 	"fmt"
+	"github.com/roboll/helmfile/pkg/remote"
 	"io"
 	"log"
 	"os"
@@ -1505,11 +1506,13 @@ helmDefaults:
 		readFile:            testFs.ReadFile,
 		glob:                testFs.Glob,
 		abs:                 testFs.Abs,
+		directoryExistsAt:   testFs.DirectoryExistsAt,
 		fileExistsAt:        testFs.FileExistsAt,
 		fileExists:          testFs.FileExists,
 		Env:                 "default",
 		Logger:              helmexec.NewLogger(os.Stderr, "debug"),
 	}
+	app.remote = remote.NewRemote(app.Logger, "", app.readFile, app.directoryExistsAt, app.fileExistsAt)
 
 	expectNoCallsToHelm(app)
 
@@ -1597,6 +1600,7 @@ helmDefaults:
 		Env:                "default",
 		Logger:             helmexec.NewLogger(os.Stderr, "debug"),
 	}
+	app.remote = remote.NewRemote(app.Logger, testFs.Cwd, testFs.ReadFile, testFs.DirectoryExistsAt, testFs.FileExistsAt)
 
 	expectNoCallsToHelm(app)
 
@@ -1675,6 +1679,7 @@ foo: FOO
 		Env:                "default",
 		Logger:             helmexec.NewLogger(os.Stderr, "debug"),
 	}
+	app.remote = remote.NewRemote(app.Logger, testFs.Cwd, testFs.ReadFile, testFs.DirectoryExistsAt, testFs.FileExistsAt)
 
 	expectNoCallsToHelm(app)
 
@@ -1740,6 +1745,7 @@ foo: FOO
 		Env:                "default",
 		Logger:             helmexec.NewLogger(os.Stderr, "debug"),
 	}
+	app.remote = remote.NewRemote(app.Logger, testFs.Cwd, testFs.ReadFile, testFs.DirectoryExistsAt, testFs.FileExistsAt)
 
 	expectNoCallsToHelm(app)
 
@@ -1823,6 +1829,7 @@ helmDefaults:
 		Env:                "test",
 		Logger:             helmexec.NewLogger(os.Stderr, "debug"),
 	}
+	app.remote = remote.NewRemote(app.Logger, testFs.Cwd, testFs.ReadFile, testFs.DirectoryExistsAt, testFs.FileExistsAt)
 
 	expectNoCallsToHelm(app)
 
@@ -1897,6 +1904,7 @@ releases:
 		Env:                "default",
 		Logger:             helmexec.NewLogger(os.Stderr, "debug"),
 	}
+	app.remote = remote.NewRemote(app.Logger, testFs.Cwd, testFs.ReadFile, testFs.DirectoryExistsAt, testFs.FileExistsAt)
 
 	expectNoCallsToHelm(app)
 
@@ -1955,6 +1963,7 @@ releases:
 		Logger:             helmexec.NewLogger(os.Stderr, "debug"),
 	}
 
+	app.remote = remote.NewRemote(app.Logger, testFs.Cwd, testFs.ReadFile, testFs.DirectoryExistsAt, testFs.FileExistsAt)
 	expectNoCallsToHelm(app)
 
 	st, err := app.loadDesiredStateFromYaml(statePath, LoadOpts{Reverse: true})
@@ -2010,6 +2019,8 @@ releases:
 			Env:                "default",
 			Logger:             helmexec.NewLogger(os.Stderr, "debug"),
 		}
+		app.remote = remote.NewRemote(app.Logger, testFs.Cwd, testFs.ReadFile, testFs.DirectoryExistsAt, testFs.FileExistsAt)
+
 		opts := LoadOpts{
 			CalleePath: statePath,
 			Environment: state.SubhelmfileEnvironmentSpec{
@@ -2122,6 +2133,7 @@ services:
 			Env:                "default",
 			Logger:             helmexec.NewLogger(os.Stderr, "debug"),
 		}
+		app.remote = remote.NewRemote(app.Logger, testFs.Cwd, testFs.ReadFile, testFs.DirectoryExistsAt, testFs.FileExistsAt)
 
 		expectNoCallsToHelm(app)
 
diff --git a/pkg/app/diff_test.go b/pkg/app/diff_test.go
index 2069fdd..8b5127e 100644
--- a/pkg/app/diff_test.go
+++ b/pkg/app/diff_test.go
@@ -172,7 +172,7 @@ releases:
 `,
 			},
 			detailedExitcode: true,
-			error:            "Identified at least on change",
+			error:            "Identified at least one change",
 			diffs: map[exectest.DiffKey]error{
 				// noop on frontend-v2
 				exectest.DiffKey{Name: "frontend-v2", Chart: "charts/frontend", Flags: "--kube-contextdefault--detailed-exitcode"}: nil,
@@ -389,7 +389,7 @@ releases:
 `,
 			},
 			detailedExitcode: true,
-			error:            "Identified at least on change",
+			error:            "Identified at least one 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},
@@ -461,7 +461,7 @@ releases:
 `,
 			},
 			detailedExitcode: true,
-			error:            "Identified at least on change",
+			error:            "Identified at least one 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},
@@ -483,7 +483,7 @@ releases:
 `,
 			},
 			detailedExitcode: true,
-			error:            "Identified at least on change",
+			error:            "Identified at least one 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},
@@ -506,7 +506,7 @@ releases:
 `,
 			},
 			detailedExitcode: true,
-			error:            "Identified at least on change",
+			error:            "Identified at least one 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},
@@ -529,7 +529,7 @@ releases:
 `,
 			},
 			detailedExitcode: true,
-			error:            "Identified at least on change",
+			error:            "Identified at least one 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},
@@ -553,7 +553,7 @@ releases:
 `,
 			},
 			detailedExitcode: true,
-			error:            "Identified at least on change",
+			error:            "Identified at least one 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},
@@ -577,7 +577,7 @@ releases:
 `,
 			},
 			detailedExitcode: true,
-			error:            "Identified at least on change",
+			error:            "Identified at least one 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},
@@ -604,7 +604,7 @@ releases:
 `,
 			},
 			detailedExitcode: true,
-			error:            "Identified at least on change",
+			error:            "Identified at least one 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},
@@ -630,7 +630,7 @@ releases:
 `,
 			},
 			detailedExitcode: true,
-			error:            "Identified at least on change",
+			error:            "Identified at least one 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},
@@ -705,7 +705,7 @@ releases:
 `,
 			},
 			detailedExitcode: true,
-			error:            "Identified at least on change",
+			error:            "Identified at least one 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},
@@ -737,7 +737,7 @@ releases:
 `,
 			},
 			detailedExitcode: true,
-			error:            "Identified at least on change",
+			error:            "Identified at least one 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},
@@ -771,7 +771,7 @@ releases:
 `,
 			},
 			detailedExitcode: true,
-			error:            "Identified at least on change",
+			error:            "Identified at least one 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},
@@ -803,7 +803,7 @@ releases:
 `,
 			},
 			detailedExitcode: true,
-			error:            "Identified at least on change",
+			error:            "Identified at least one 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},
@@ -835,7 +835,7 @@ releases:
 `,
 			},
 			detailedExitcode: true,
-			error:            "Identified at least on change",
+			error:            "Identified at least one 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},
@@ -867,7 +867,7 @@ releases:
 `,
 			},
 			detailedExitcode: true,
-			error:            "Identified at least on change",
+			error:            "Identified at least one 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},
@@ -925,7 +925,7 @@ releases:
 			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",
+			error:       "Identified at least one 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[]}
diff --git a/pkg/app/two_pass_renderer_test.go b/pkg/app/two_pass_renderer_test.go
index d21450a..ce94535 100644
--- a/pkg/app/two_pass_renderer_test.go
+++ b/pkg/app/two_pass_renderer_test.go
@@ -1,6 +1,7 @@
 package app
 
 import (
+	"github.com/roboll/helmfile/pkg/remote"
 	"os"
 	"strings"
 	"testing"
@@ -14,6 +15,8 @@ import (
 
 func makeLoader(files map[string]string, env string) (*desiredStateLoader, *testhelper.TestFs) {
 	testfs := testhelper.NewTestFs(files)
+	logger := helmexec.NewLogger(os.Stdout, "debug")
+	r := remote.NewRemote(logger, testfs.Cwd, testfs.ReadFile, testfs.DirectoryExistsAt, testfs.FileExistsAt)
 	return &desiredStateLoader{
 		env:        env,
 		namespace:  "namespace",
@@ -22,6 +25,7 @@ func makeLoader(files map[string]string, env string) (*desiredStateLoader, *test
 		fileExists: testfs.FileExists,
 		abs:        testfs.Abs,
 		glob:       testfs.Glob,
+		remote:     r,
 	}, testfs
 }
 
diff --git a/pkg/state/create_test.go b/pkg/state/create_test.go
index b518de7..441bb31 100644
--- a/pkg/state/create_test.go
+++ b/pkg/state/create_test.go
@@ -1,6 +1,7 @@
 package state
 
 import (
+	"github.com/roboll/helmfile/pkg/remote"
 	"io/ioutil"
 	"path/filepath"
 	"reflect"
@@ -108,7 +109,9 @@ bar: {{ readFile "bar.txt" }}
 	})
 	testFs.Cwd = "/example/path/to"
 
-	state, err := NewCreator(logger, testFs.ReadFile, testFs.FileExists, testFs.Abs, testFs.Glob, nil, nil, "").ParseAndLoad(yamlContent, filepath.Dir(yamlFile), yamlFile, "production", true, nil)
+	r := remote.NewRemote(logger, testFs.Cwd, testFs.ReadFile, testFs.DirectoryExistsAt, testFs.FileExistsAt)
+	state, err := NewCreator(logger, testFs.ReadFile, testFs.FileExists, testFs.Abs, testFs.Glob, nil, nil, "", r).
+		ParseAndLoad(yamlContent, filepath.Dir(yamlFile), yamlFile, "production", true, nil)
 	if err != nil {
 		t.Fatalf("unexpected error: %v", err)
 	}
diff --git a/pkg/state/envvals_loader_test.go b/pkg/state/envvals_loader_test.go
index f4ed186..bb6feb2 100644
--- a/pkg/state/envvals_loader_test.go
+++ b/pkg/state/envvals_loader_test.go
@@ -2,6 +2,7 @@ package state
 
 import (
 	"github.com/google/go-cmp/cmp"
+	"github.com/roboll/helmfile/pkg/remote"
 	"go.uber.org/zap"
 	"io/ioutil"
 	"path/filepath"
@@ -23,7 +24,10 @@ func newLoader() *EnvironmentValuesLoader {
 		logger:   sugar,
 	}
 
-	return NewEnvironmentValuesLoader(storage, ioutil.ReadFile, sugar)
+	readFile := func(s string) ([]byte, error) { return []byte{}, nil }
+	dirExists := func(d string) bool { return false }
+	fileExists := func(f string) bool { return false }
+	return NewEnvironmentValuesLoader(storage, ioutil.ReadFile, sugar, remote.NewRemote(sugar, "/tmp", readFile, dirExists, fileExists))
 }
 
 // See https://github.com/roboll/helmfile/pull/1169