diff --git a/docs/index.md b/docs/index.md index 106f701c..f43d48b9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -367,6 +367,8 @@ We also added the following functions: - `exec` - `envExec` - `readFile` +- `readDir` +- `readDirEntries` - `toYaml` - `fromYaml` - `setValueAtPath` @@ -679,6 +681,8 @@ You can use go's text/template expressions in `helmfile.yaml` and `values.yaml.g In addition to built-in ones, the following custom template functions are available: - `readFile` reads the specified local file and generate a golang string +- `readDir` reads the files within provided directory path. (folders are excluded) +- `readDirEntries` Returns a list of [https://pkg.go.dev/os#DirEntry](DirEntry) within provided directory path - `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 diff --git a/pkg/tmpl/context.go b/pkg/tmpl/context.go index 2cfce655..2b7ff38a 100644 --- a/pkg/tmpl/context.go +++ b/pkg/tmpl/context.go @@ -1,9 +1,12 @@ package tmpl +import "io/fs" + type Context struct { preRender bool basePath string readFile func(string) ([]byte, error) + readDir func(string) ([]fs.DirEntry, error) } // SetBasePath sets the base path for the template @@ -14,3 +17,7 @@ func (c *Context) SetBasePath(path string) { func (c *Context) SetReadFile(f func(string) ([]byte, error)) { c.readFile = f } + +func (c *Context) SetReadDir(f func(string) ([]fs.DirEntry, error)) { + c.readDir = f +} diff --git a/pkg/tmpl/context_funcs.go b/pkg/tmpl/context_funcs.go index c95d589c..1867a579 100644 --- a/pkg/tmpl/context_funcs.go +++ b/pkg/tmpl/context_funcs.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "io" + "io/fs" "os" "os/exec" "path/filepath" @@ -47,6 +48,7 @@ func (c *Context) createFuncMap() template.FuncMap { "isFile": c.IsFile, "readFile": c.ReadFile, "readDir": c.ReadDir, + "readDirEntries": c.ReadDirEntries, "toYaml": ToYaml, "fromYaml": FromYaml, "setValueAtPath": SetValueAtPath, @@ -69,6 +71,12 @@ func (c *Context) createFuncMap() template.FuncMap { funcMap["readFile"] = func(string) (string, error) { return "", nil } + funcMap["readDir"] = func(string) ([]string, error) { + return []string{}, nil + } + funcMap["readDirEntries"] = func(string) ([]fs.DirEntry, error) { + return []fs.DirEntry{}, nil + } } if disableInsecureFeatures { // disable insecure functions @@ -78,6 +86,12 @@ func (c *Context) createFuncMap() template.FuncMap { funcMap["readFile"] = func(string) (string, error) { return "", DisableInsecureFeaturesErr } + funcMap["readDir"] = func(string) ([]string, error) { + return nil, DisableInsecureFeaturesErr + } + funcMap["readDirEntries"] = func(string) ([]string, error) { + return nil, DisableInsecureFeaturesErr + } } return funcMap @@ -224,20 +238,34 @@ func (c *Context) ReadDir(path string) ([]string, error) { contextPath = filepath.Join(c.basePath, path) } - entries, err := os.ReadDir(contextPath) + entries, err := c.readDir(contextPath) if err != nil { return nil, fmt.Errorf("ReadDir %q: %w", contextPath, err) } - var filenames []string + var paths []string for _, entry := range entries { if entry.IsDir() { continue } - filenames = append(filenames, filepath.Join(path, entry.Name())) + paths = append(paths, filepath.Join(path, entry.Name())) } - return filenames, nil + return paths, nil +} + +func (c *Context) ReadDirEntries(path string) ([]fs.DirEntry, error) { + var contextPath string + if filepath.IsAbs(path) { + contextPath = path + } else { + contextPath = filepath.Join(c.basePath, path) + } + entries, err := c.readDir(contextPath) + if err != nil { + return nil, fmt.Errorf("ReadDirEntries %q: %w", contextPath, err) + } + return entries, nil } func (c *Context) Tpl(text string, data interface{}) (string, error) { diff --git a/pkg/tmpl/context_funcs_test.go b/pkg/tmpl/context_funcs_test.go index 8d332eaa..6cad20ea 100644 --- a/pkg/tmpl/context_funcs_test.go +++ b/pkg/tmpl/context_funcs_test.go @@ -3,11 +3,11 @@ package tmpl import ( "encoding/json" "fmt" + "io/fs" "path/filepath" - "reflect" + "runtime" "testing" - "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/require" ) @@ -74,12 +74,88 @@ func TestReadFile(t *testing.T) { return []byte(expected), nil }} actual, err := ctx.ReadFile(expectedFilename) - if err != nil { - t.Errorf("unexpected error: %v", err) + require.NoError(t, err) + require.Equal(t, expected, actual) +} + +type entry struct { + name string + fType fs.FileMode + isDir bool +} + +func (e *entry) Name() string { + return e.name +} + +func (e *entry) IsDir() bool { + return e.isDir +} + +func (e *entry) Type() fs.FileMode { + return e.fType +} + +func (e *entry) Info() (fs.FileInfo, error) { + return nil, fmt.Errorf("You should not call this method") +} + +func TestReadDir(t *testing.T) { + result := []fs.DirEntry{ + &entry{name: "file1.yaml"}, + &entry{name: "file2.yaml"}, + &entry{name: "file3.yaml"}, + &entry{name: "folder1", isDir: true}, } - if !reflect.DeepEqual(actual, expected) { - t.Errorf("unexpected result: expected=%v, actual=%v", expected, actual) + expectedArrayWindows := []string{ + "sampleDirectory\\file1.yaml", + "sampleDirectory\\file2.yaml", + "sampleDirectory\\file3.yaml", } + expectedArrayUnix := []string{ + "sampleDirectory/file1.yaml", + "sampleDirectory/file2.yaml", + "sampleDirectory/file3.yaml", + } + var expectedArray []string + if runtime.GOOS == "windows" { + expectedArray = expectedArrayWindows + } else { + expectedArray = expectedArrayUnix + } + + expectedDirname := "sampleDirectory" + ctx := &Context{basePath: ".", readDir: func(dirname string) ([]fs.DirEntry, error) { + if dirname != expectedDirname { + return nil, fmt.Errorf("unexpected filename: expected=%v, actual=%s", expectedDirname, dirname) + } + return result, nil + }} + + actual, err := ctx.ReadDir(expectedDirname) + require.NoError(t, err) + require.ElementsMatch(t, expectedArray, actual) +} + +func TestReadDirEntries(t *testing.T) { + result := []fs.DirEntry{ + &entry{name: "file1.yaml"}, + &entry{name: "file2.yaml"}, + &entry{name: "file3.yaml"}, + &entry{name: "folder1", isDir: true}, + } + + expectedDirname := "sampleDirectory" + ctx := &Context{basePath: ".", readDir: func(dirname string) ([]fs.DirEntry, error) { + if dirname != expectedDirname { + return nil, fmt.Errorf("unexpected filename: expected=%v, actual=%s", expectedDirname, dirname) + } + return result, nil + }} + + actual, err := ctx.ReadDirEntries(expectedDirname) + require.NoError(t, err) + require.ElementsMatch(t, result, actual) } func TestReadFile_PassAbsPath(t *testing.T) { @@ -94,12 +170,8 @@ func TestReadFile_PassAbsPath(t *testing.T) { return []byte(expected), nil }} actual, err := ctx.ReadFile(expectedFilename) - if err != nil { - t.Errorf("unexpected error: %v", err) - } - if !reflect.DeepEqual(actual, expected) { - t.Errorf("unexpected result: expected=%v, actual=%v", expected, actual) - } + require.NoError(t, err) + require.Equal(t, actual, expected) } func TestToYaml_UnsupportedNestedMapKey(t *testing.T) { @@ -111,15 +183,8 @@ func TestToYaml_UnsupportedNestedMapKey(t *testing.T) { }, }) actual, err := ToYaml(vals) - if err == nil { - t.Fatalf("expected error but got none") - } else if err.Error() != "error marshaling into JSON: json: unsupported type: map[interface {}]interface {}" { - t.Fatalf("unexpected error: %v", err) - } - - if !reflect.DeepEqual(actual, expected) { - t.Errorf("unexpected result: expected=%v, actual=%v", expected, actual) - } + require.Error(t, err, "error marshaling into JSON: json: unsupported type: map[interface {}]interface {}") + require.Equal(t, expected, actual) } func TestToYaml(t *testing.T) { @@ -133,12 +198,8 @@ func TestToYaml(t *testing.T) { }, }) actual, err := ToYaml(vals) - if err != nil { - t.Errorf("unexpected error: %v", err) - } - if !reflect.DeepEqual(actual, expected) { - t.Errorf("unexpected result: expected=%v, actual=%v", expected, actual) - } + require.NoError(t, err) + require.Equal(t, expected, actual) } func TestFromYaml(t *testing.T) { @@ -152,12 +213,8 @@ func TestFromYaml(t *testing.T) { }, }) actual, err := FromYaml(raw) - if err != nil { - t.Errorf("unexpected error: %v", err) - } - if !reflect.DeepEqual(actual, expected) { - t.Errorf("unexpected result: expected=%v, actual=%v", expected, actual) - } + require.NoError(t, err) + require.Equal(t, expected, actual) } func TestFromYamlToJson(t *testing.T) { @@ -167,18 +224,11 @@ func TestFromYamlToJson(t *testing.T) { want := `{"foo":{"bar":"BAR"}}` m, err := FromYaml(input) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } + require.NoError(t, err) got, err := json.Marshal(m) - if err != nil { - t.Errorf("unexpected error: %v", err) - } - - if d := cmp.Diff(want, string(got)); d != "" { - t.Errorf("unexpected result: want (-), got (+):\n%s", d) - } + require.NoError(t, err) + require.Equal(t, string(got), want) } func TestSetValueAtPath_OneComponent(t *testing.T) { @@ -189,12 +239,8 @@ func TestSetValueAtPath_OneComponent(t *testing.T) { "foo": "FOO", } actual, err := SetValueAtPath("foo", "FOO", input) - if err != nil { - t.Errorf("unexpected error: %v", err) - } - if !reflect.DeepEqual(actual, expected) { - t.Errorf("unexpected result: expected=%v, actual=%v", expected, actual) - } + require.NoError(t, err) + require.Equal(t, expected, actual) } func TestSetValueAtPath_TwoComponents(t *testing.T) { @@ -209,12 +255,8 @@ func TestSetValueAtPath_TwoComponents(t *testing.T) { }, } actual, err := SetValueAtPath("foo.bar", "FOO_BAR", input) - if err != nil { - t.Errorf("unexpected error: %v", err) - } - if !reflect.DeepEqual(actual, expected) { - t.Errorf("unexpected result: expected=%v, actual=%v", expected, actual) - } + require.NoError(t, err) + require.Equal(t, expected, actual) } func TestTpl(t *testing.T) { @@ -224,12 +266,8 @@ func TestTpl(t *testing.T) { ` ctx := &Context{basePath: "."} actual, err := ctx.Tpl(text, map[string]interface{}{"foo": "FOO"}) - if err != nil { - t.Errorf("unexpected error: %v", err) - } - if !reflect.DeepEqual(actual, expected) { - t.Errorf("unexpected result: expected=%v, actual=%v", expected, actual) - } + require.NoError(t, err) + require.Equal(t, expected, actual) } func TestRequired(t *testing.T) { @@ -266,13 +304,12 @@ func TestRequired(t *testing.T) { testCase := tt t.Run(testCase.name, func(t *testing.T) { got, err := Required(testCase.args.warn, testCase.args.val) - if (err != nil) != testCase.wantErr { - t.Errorf("Required() error = %v, wantErr %v", err, testCase.wantErr) - return - } - if !reflect.DeepEqual(got, testCase.want) { - t.Errorf("Required() got = %v, want %v", got, testCase.want) + if testCase.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) } + require.Equal(t, testCase.want, got) }) } } diff --git a/test/e2e/template/helmfile/testdata/tmpl/sample_folder/file1.txt b/test/e2e/template/helmfile/testdata/tmpl/sample_folder/file1.txt new file mode 100644 index 00000000..e69de29b diff --git a/test/e2e/template/helmfile/testdata/tmpl/sample_folder/file2.txt b/test/e2e/template/helmfile/testdata/tmpl/sample_folder/file2.txt new file mode 100644 index 00000000..e69de29b diff --git a/test/e2e/template/helmfile/testdata/tmpl/sample_folder/sub_folder/file3.txt b/test/e2e/template/helmfile/testdata/tmpl/sample_folder/sub_folder/file3.txt new file mode 100644 index 00000000..e69de29b diff --git a/test/e2e/template/helmfile/tmpl_test.go b/test/e2e/template/helmfile/tmpl_test.go index 072ce34b..6944817e 100644 --- a/test/e2e/template/helmfile/tmpl_test.go +++ b/test/e2e/template/helmfile/tmpl_test.go @@ -103,6 +103,50 @@ var readFileTestCases = []tmplTestCase{ }, } +var readDirTestCases = []tmplTestCase{ + { + name: "readDir", + tmplString: `{{ range $index,$item := readDir "./testdata/tmpl/sample_folder/" }} + {{- $itemSplit := splitList "/" $item -}} + {{- if contains "\\" $item -}} + {{- $itemSplit = splitList "\\" $item -}} + {{- end -}} + {{- $itemValue := $itemSplit | last -}} + {{- $itemValue -}} + {{- end -}}`, + output: "file1.txtfile2.txt", + }, + { + name: "readDirWithError", + tmplString: `{{ readFile "./testdata/tmpl/sample_folder_error/" }}`, + wantErr: true, + }, +} + +var readDirEntriesTestCases = []tmplTestCase{ + { + name: "readDirEntries", + tmplString: `{{ range $index,$item := readDirEntries "./testdata/tmpl/sample_folder/" }} + {{- $item.Name -}} + {{- end -}}`, + output: "file1.txtfile2.txtsub_folder", + }, + { + name: "readDirEntriesOnlyFolders", + tmplString: `{{ range $index,$item := readDirEntries "./testdata/tmpl/sample_folder/" }} + {{- if $item.IsDir -}} + {{- $item.Name -}} + {{- end -}} + {{- end -}}`, + output: "sub_folder", + }, + { + name: "readDirEntriesWithError", + tmplString: `{{ readDirEntries "./testdata/tmpl/sample_folder_error/" }}`, + wantErr: true, + }, +} + var toYamlTestCases = []tmplTestCase{ { data: map[string]string{ @@ -208,6 +252,8 @@ func (t *tmplE2e) load() { t.append(envExecTestCases...) t.append(execTestCases...) t.append(readFileTestCases...) + t.append(readDirTestCases...) + t.append(readDirEntriesTestCases...) t.append(toYamlTestCases...) t.append(fromYamlTestCases...) t.append(setValueAtPathTestCases...) @@ -222,6 +268,7 @@ func TestTmplStrings(t *testing.T) { c := &tmpl.Context{} c.SetBasePath(".") c.SetReadFile(os.ReadFile) + c.SetReadDir(os.ReadDir) tmpl := template.New("stringTemplateTest").Funcs(c.CreateFuncMap()) tmplE2eTest.load()