diff --git a/lang/funcs/filesystem.go b/lang/funcs/filesystem.go index d3a7c38b7..af3c4c856 100644 --- a/lang/funcs/filesystem.go +++ b/lang/funcs/filesystem.go @@ -63,6 +63,49 @@ func MakeFileFunc(baseDir string, encBase64 bool) function.Function { }) } +// MakeFileExistsFunc constructs a function that takes a path +// and determines whether a file exists at that path +func MakeFileExistsFunc(baseDir string) function.Function { + return function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "path", + Type: cty.String, + }, + }, + Type: function.StaticReturnType(cty.Bool), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + path := args[0].AsString() + path, err := homedir.Expand(path) + if err != nil { + return cty.UnknownVal(cty.Bool), fmt.Errorf("failed to expand ~: %s", err) + } + + if !filepath.IsAbs(path) { + path = filepath.Join(baseDir, path) + } + + // Ensure that the path is canonical for the host OS + path = filepath.Clean(path) + + fi, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return cty.False, nil + } + return cty.UnknownVal(cty.Bool), fmt.Errorf("failed to stat %s", path) + } + + if fi.Mode().IsRegular() { + return cty.True, nil + } + + return cty.False, fmt.Errorf("%s is not a regular file, but %q", + path, fi.Mode().String()) + }, + }) +} + // BasenameFunc constructs a function that takes a string containing a filesystem path // and removes all except the last portion from it. var BasenameFunc = function.New(&function.Spec{ @@ -121,6 +164,16 @@ func File(baseDir string, path cty.Value) (cty.Value, error) { return fn.Call([]cty.Value{path}) } +// FileExists determines whether a file exists at the given path. +// +// The underlying function implementation works relative to a particular base +// directory, so this wrapper takes a base directory string and uses it to +// construct the underlying function before calling it. +func FileExists(baseDir string, path cty.Value) (cty.Value, error) { + fn := MakeFileExistsFunc(baseDir) + return fn.Call([]cty.Value{path}) +} + // FileBase64 reads the contents of the file at the given path. // // The bytes from the file are encoded as base64 before returning. diff --git a/lang/funcs/filesystem_test.go b/lang/funcs/filesystem_test.go index 18f27cc81..0414ab661 100644 --- a/lang/funcs/filesystem_test.go +++ b/lang/funcs/filesystem_test.go @@ -52,6 +52,49 @@ func TestFile(t *testing.T) { } } +func TestFileExists(t *testing.T) { + tests := []struct { + Path cty.Value + Want cty.Value + Err bool + }{ + { + cty.StringVal("testdata/hello.txt"), + cty.BoolVal(true), + false, + }, + { + cty.StringVal(""), // empty path + cty.BoolVal(false), + true, + }, + { + cty.StringVal("testdata/missing"), + cty.BoolVal(false), + false, // no file exists + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("FileExists(\".\", %#v)", test.Path), func(t *testing.T) { + got, err := FileExists(".", test.Path) + + if test.Err { + if err == nil { + t.Fatal("succeeded; want error") + } + return + } else if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !got.RawEquals(test.Want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) + } + }) + } +} + func TestFileBase64(t *testing.T) { tests := []struct { Path cty.Value diff --git a/lang/functions.go b/lang/functions.go index f8298082d..a97909eab 100644 --- a/lang/functions.go +++ b/lang/functions.go @@ -53,6 +53,7 @@ func (s *Scope) Functions() map[string]function.Function { "element": funcs.ElementFunc, "chunklist": funcs.ChunklistFunc, "file": funcs.MakeFileFunc(s.BaseDir, false), + "fileexists": funcs.MakeFileExistsFunc(s.BaseDir), "filebase64": funcs.MakeFileFunc(s.BaseDir, true), "flatten": funcs.FlattenFunc, "floor": funcs.FloorFunc, diff --git a/website/docs/configuration/functions/file.html.md b/website/docs/configuration/functions/file.html.md index d1bd82e1e..43ec08532 100644 --- a/website/docs/configuration/functions/file.html.md +++ b/website/docs/configuration/functions/file.html.md @@ -42,3 +42,5 @@ Hello World * [`filebase64`](./filebase64.html) also reads the contents of a given file, but returns the raw bytes in that file Base64-encoded, rather than interpreting the contents as UTF-8 text. +* [`fileexists`](./fileexists.html) determines whether a file exists + at a given path. diff --git a/website/docs/configuration/functions/fileexists.html.md b/website/docs/configuration/functions/fileexists.html.md new file mode 100644 index 000000000..32568b538 --- /dev/null +++ b/website/docs/configuration/functions/fileexists.html.md @@ -0,0 +1,37 @@ +--- +layout: "functions" +page_title: "fileexists function" +sidebar_current: "docs-funcs-file-file-exists" +description: |- + The fileexists function determines whether a file exists at a given path. +--- + +# `fileexists` Function + +`fileexists` determines whether a file exists at a given path. + +```hcl +fileexists(path) +``` + +Functions are evaluated during configuration parsing rather than at apply time, +so this function can only be used with files that are already present on disk +before Terraform takes any actions. + +This function works only with regular files. If used with a directory, FIFO, +or other special mode, it will return an error. + +## Examples + +``` +> fileexists("${path.module}/hello.txt") +true +``` + +```hcl +fileexists("custom-section.sh") ? file("custom-section.sh") : local.default_content +``` + +## Related Functions + +* [`file`](./file.html) reads the contents of a file at a given path diff --git a/website/layouts/functions.erb b/website/layouts/functions.erb index 6b199873a..f01aa4112 100644 --- a/website/layouts/functions.erb +++ b/website/layouts/functions.erb @@ -249,6 +249,10 @@ file + > + fileexists + + > filebase64