diff --git a/.changes/unreleased/FEATURES-20240116-180627.yaml b/.changes/unreleased/FEATURES-20240116-180627.yaml new file mode 100644 index 00000000..253f7f2c --- /dev/null +++ b/.changes/unreleased/FEATURES-20240116-180627.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'functions/direxists: Added a new `direxists` function that checks for the existence + of a directory, similar to the built-in `fileexists` function.' +time: 2024-01-16T18:06:27.665639-05:00 +custom: + Issue: "285" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b9c04cd5..e7af80d3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,6 +37,13 @@ jobs: with: version: latest + # TODO: Temporary addition to ensure plugin-docs uses the v1.8.0-beta1 version of Terraform. This should be reverted once Terraform v1.8.0 is released. + - name: Setup Terraform (supporting provider functions) + uses: hashicorp/setup-terraform@a1502cd9e758c50496cc9ac5308c4843bcd56d36 # v3.0.0 + with: + terraform_version: v1.8.0-beta1 + terraform_wrapper: false + - name: Generate run: make generate @@ -73,7 +80,10 @@ jobs: - name: Setup Terraform ${{ matrix.terraform }} uses: hashicorp/setup-terraform@a1502cd9e758c50496cc9ac5308c4843bcd56d36 # v3.0.0 with: - terraform_version: ${{ matrix.terraform }}.* + # TODO: Temporary change has been made to `vars.TF_VERSIONS_PROTOCOL_V5` to include the `.*` to enable us + # to utilize the v1.8.0-beta1 of Terraform. This should be reverted once Terraform v1.8.0 is released. + # terraform_version: ${{ matrix.terraform }}.* + terraform_version: ${{ matrix.terraform }} terraform_wrapper: false - name: Run acceptance test diff --git a/docs/functions/direxists.md b/docs/functions/direxists.md new file mode 100644 index 00000000..5338ffe0 --- /dev/null +++ b/docs/functions/direxists.md @@ -0,0 +1,67 @@ +page_title: "direxists function - terraform-provider-local" +subcategory: "" +description: |- + Determines whether a directory exists at a given path. +--- + +# function: direxists + +Given a path string, will return true if the directory exists. This function works only with directories. If used with a file, the function will return an error. + +This function behaves similar to the built-in [`fileexists`](https://developer.hashicorp.com/terraform/language/functions/fileexists) function, however, `direxists` will not replace filesystem paths including `~` with the current user's home directory path. This functionality can be achieved by using the built-in [`pathexpand`](https://developer.hashicorp.com/terraform/language/functions/pathexpand) function with `direxists`, see example below. + +## Example Usage + +### Basic Usage + +```terraform +# Configuration using provider functions must include required_providers configuration. +terraform { + required_providers { + local = { + source = "hashicorp/local" + # Setting the provider version is a strongly recommended practice + # version = "..." + } + } + # Provider functions require Terraform 1.8 and later. + required_version = ">= 1.8.0" +} + +output "example_output" { + value = provider::local::direxists("${path.module}/example-directory") +} +``` + +### Usage with home directory + +```terraform +# Configuration using provider functions must include required_providers configuration. +terraform { + required_providers { + local = { + source = "hashicorp/local" + # Setting the provider version is a strongly recommended practice + # version = "..." + } + } + # Provider functions require Terraform 1.8 and later. + required_version = ">= 1.8.0" +} + +output "example_output_homedir" { + value = provider::local::direxists(pathexpand("~/.ssh")) +} +``` + +## Signature + + +```text +direxists(path string) bool +``` + +## Arguments + + +1. `path` (String) Relative or absolute path to check for the existence of a directory diff --git a/examples/functions/direxists/basic.tf b/examples/functions/direxists/basic.tf new file mode 100644 index 00000000..bbc308c7 --- /dev/null +++ b/examples/functions/direxists/basic.tf @@ -0,0 +1,16 @@ +# Configuration using provider functions must include required_providers configuration. +terraform { + required_providers { + local = { + source = "hashicorp/local" + # Setting the provider version is a strongly recommended practice + # version = "..." + } + } + # Provider functions require Terraform 1.8 and later. + required_version = ">= 1.8.0" +} + +output "example_output" { + value = provider::local::direxists("${path.module}/example-directory") +} diff --git a/examples/functions/direxists/example-directory/.gitkeep b/examples/functions/direxists/example-directory/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/examples/functions/direxists/homedir.tf b/examples/functions/direxists/homedir.tf new file mode 100644 index 00000000..30b84636 --- /dev/null +++ b/examples/functions/direxists/homedir.tf @@ -0,0 +1,16 @@ +# Configuration using provider functions must include required_providers configuration. +terraform { + required_providers { + local = { + source = "hashicorp/local" + # Setting the provider version is a strongly recommended practice + # version = "..." + } + } + # Provider functions require Terraform 1.8 and later. + required_version = ">= 1.8.0" +} + +output "example_output_homedir" { + value = provider::local::direxists(pathexpand("~/.ssh")) +} diff --git a/go.mod b/go.mod index 21ea2cfb..c785044d 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.21 toolchain go1.21.6 require ( + github.com/hashicorp/go-version v1.6.0 github.com/hashicorp/terraform-plugin-framework v1.6.1 github.com/hashicorp/terraform-plugin-framework-validators v0.12.0 github.com/hashicorp/terraform-plugin-go v0.22.0 @@ -27,7 +28,6 @@ require ( github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-plugin v1.6.0 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect - github.com/hashicorp/go-version v1.6.0 // indirect github.com/hashicorp/hc-install v0.6.3 // indirect github.com/hashicorp/hcl/v2 v2.20.0 // indirect github.com/hashicorp/logutils v1.0.0 // indirect diff --git a/internal/provider/data_source_local_file_test.go b/internal/provider/data_source_local_file_test.go index cb9d6293..750538c3 100644 --- a/internal/provider/data_source_local_file_test.go +++ b/internal/provider/data_source_local_file_test.go @@ -7,6 +7,7 @@ import ( "encoding/base64" "testing" + "github.com/hashicorp/terraform-plugin-testing/config" "github.com/hashicorp/terraform-plugin-testing/helper/resource" ) @@ -14,17 +15,11 @@ func TestLocalFileDataSource(t *testing.T) { content := "This is some content" checkSums := genFileChecksums([]byte(content)) - config := ` - data "local_file" "file" { - filename = "./testdata/local_file" - } - ` - resource.UnitTest(t, resource.TestCase{ ProtoV5ProviderFactories: protoV5ProviderFactories(), Steps: []resource.TestStep{ { - Config: config, + ConfigDirectory: config.TestNameDirectory(), Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr("data.local_file.file", "content", content), resource.TestCheckResourceAttr("data.local_file.file", "content_base64", base64.StdEncoding.EncodeToString([]byte(content))), diff --git a/internal/provider/data_source_local_sensitive_file_test.go b/internal/provider/data_source_local_sensitive_file_test.go index 0bc1da66..976553a8 100644 --- a/internal/provider/data_source_local_sensitive_file_test.go +++ b/internal/provider/data_source_local_sensitive_file_test.go @@ -9,24 +9,19 @@ import ( "testing" "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-testing/config" "github.com/hashicorp/terraform-plugin-testing/helper/resource" ) func TestLocalFileSensitiveDataSource(t *testing.T) { - testFileContent := "This is some content" + testFileContent := "This is some sensitive content" checkSums := genFileChecksums([]byte(testFileContent)) - config := ` - data "local_sensitive_file" "file" { - filename = "./testdata/local_file" - } - ` - resource.UnitTest(t, resource.TestCase{ ProtoV5ProviderFactories: protoV5ProviderFactories(), Steps: []resource.TestStep{ { - Config: config, + ConfigDirectory: config.TestNameDirectory(), Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr("data.local_sensitive_file.file", "content", testFileContent), resource.TestCheckResourceAttr("data.local_sensitive_file.file", "content_base64", base64.StdEncoding.EncodeToString([]byte(testFileContent))), diff --git a/internal/provider/function_direxists.go b/internal/provider/function_direxists.go new file mode 100644 index 00000000..a907f28d --- /dev/null +++ b/internal/provider/function_direxists.go @@ -0,0 +1,83 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var _ function.Function = &DirectoryExistsFunction{} + +type DirectoryExistsFunction struct{} + +func NewDirectoryExistsFunction() function.Function { + return &DirectoryExistsFunction{} +} + +func (f *DirectoryExistsFunction) Metadata(ctx context.Context, req function.MetadataRequest, resp *function.MetadataResponse) { + resp.Name = "direxists" +} + +func (f *DirectoryExistsFunction) Definition(ctx context.Context, req function.DefinitionRequest, resp *function.DefinitionResponse) { + resp.Definition = function.Definition{ + Summary: "Determines whether a directory exists at a given path.", + Description: "Given a path string, will return true if the directory exists. " + + "This function works only with directories. If used with a file, the function will return an error.\n\n" + + "This function behaves similar to the built-in [`fileexists`](https://developer.hashicorp.com/terraform/language/functions/fileexists) function, " + + "however, `direxists` will not replace filesystem paths including `~` with the current user's home directory path. This functionality can be achieved by using the built-in " + + "[`pathexpand`](https://developer.hashicorp.com/terraform/language/functions/pathexpand) function with `direxists`, see example below.", + + Parameters: []function.Parameter{ + function.StringParameter{ + Name: "path", + Description: "Relative or absolute path to check for the existence of a directory", + }, + }, + Return: function.BoolReturn{}, + } +} + +func (f *DirectoryExistsFunction) Run(ctx context.Context, req function.RunRequest, resp *function.RunResponse) { + var inputPath string + + resp.Error = req.Arguments.Get(ctx, &inputPath) + if resp.Error != nil { + return + } + + directoryPath := inputPath + if !filepath.IsAbs(directoryPath) { + var err error + directoryPath, err = filepath.Abs(directoryPath) + if err != nil { + resp.Error = function.NewArgumentFuncError(0, fmt.Sprintf("Error expanding relative path to absolute path: %s", err)) + return + } + } + + directoryPath = filepath.Clean(directoryPath) + + fi, err := os.Stat(directoryPath) + if err != nil { + if os.IsNotExist(err) { + resp.Error = resp.Result.Set(ctx, types.BoolValue(false)) + return + } else { + resp.Error = function.NewArgumentFuncError(0, fmt.Sprintf("Error checking for directory: %s", err)) + return + } + } + + if fi.IsDir() { + resp.Error = resp.Result.Set(ctx, types.BoolValue(true)) + return + } + resp.Error = function.NewArgumentFuncError(0, fmt.Sprintf("Invalid file mode detected: %q was found, but is not a directory", inputPath)) +} diff --git a/internal/provider/function_direxists_test.go b/internal/provider/function_direxists_test.go new file mode 100644 index 00000000..0a298237 --- /dev/null +++ b/internal/provider/function_direxists_test.go @@ -0,0 +1,69 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "regexp" + "testing" + + "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform-plugin-testing/config" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/tfversion" +) + +func TestDirectoryExists_basic(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + // TODO: Replace with the stable v1.8.0 release when available + tfversion.SkipBelow(version.Must(version.NewVersion("v1.8.0-beta1"))), + }, + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + ConfigDirectory: config.TestNameDirectory(), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectKnownOutputValue("test_dir_exists", knownvalue.Bool(true)), + plancheck.ExpectKnownOutputValue("test_dir_doesnt_exist", knownvalue.Bool(false)), + }, + }, + }, + }, + }) +} + +func TestDirectoryExists_invalid_file(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + // TODO: Replace with the stable v1.8.0 release when available + tfversion.SkipBelow(version.Must(version.NewVersion("v1.8.0-beta1"))), + }, + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + ConfigDirectory: config.TestNameDirectory(), + ExpectError: regexp.MustCompile("\"./testdata/TestDirectoryExists_invalid_file/not_a_dir.txt\" was found, but is\nnot a directory."), + }, + }, + }) +} + +func TestDirectoryExists_invalid_symlink(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + // TODO: Replace with the stable v1.8.0 release when available + tfversion.SkipBelow(version.Must(version.NewVersion("v1.8.0-beta1"))), + }, + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + ConfigDirectory: config.TestNameDirectory(), + ExpectError: regexp.MustCompile("\"./testdata/TestDirectoryExists_invalid_symlink/not_a_dir_symlink\" was found,\nbut is not a directory."), + }, + }, + }) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index badc47eb..907ce2f3 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -13,13 +13,14 @@ import ( "encoding/hex" "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/function" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/resource" ) var ( - _ provider.Provider = (*localProvider)(nil) + _ provider.ProviderWithFunctions = (*localProvider)(nil) ) func New() provider.Provider { @@ -50,6 +51,12 @@ func (p *localProvider) Resources(ctx context.Context) []func() resource.Resourc } } +func (p *localProvider) Functions(ctx context.Context) []func() function.Function { + return []func() function.Function{ + NewDirectoryExistsFunction, + } +} + func (p *localProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) { resp.Schema = schema.Schema{} } diff --git a/internal/provider/testdata/TestDirectoryExists_basic/main.tf b/internal/provider/testdata/TestDirectoryExists_basic/main.tf new file mode 100644 index 00000000..b6da5bfa --- /dev/null +++ b/internal/provider/testdata/TestDirectoryExists_basic/main.tf @@ -0,0 +1,23 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +terraform { + required_providers { + local = { + source = "hashicorp/local" + } + } +} + +output "test_dir_exists" { + # Known issue where relative path is based on where the test working directory is located: + # https://github.com/hashicorp/terraform-plugin-testing/issues/277 + value = provider::local::direxists("${path.module}/testdata/TestDirectoryExists_basic/test_dir") +} + + +output "test_dir_doesnt_exist" { + # Known issue where relative path is based on where the test working directory is located: + # https://github.com/hashicorp/terraform-plugin-testing/issues/277 + value = provider::local::direxists("${path.module}/testdata/TestDirectoryExists_basic/nothing_here") +} diff --git a/internal/provider/testdata/TestDirectoryExists_basic/test_dir/.gitkeep b/internal/provider/testdata/TestDirectoryExists_basic/test_dir/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/internal/provider/testdata/TestDirectoryExists_invalid_file/main.tf b/internal/provider/testdata/TestDirectoryExists_invalid_file/main.tf new file mode 100644 index 00000000..2b187bdf --- /dev/null +++ b/internal/provider/testdata/TestDirectoryExists_invalid_file/main.tf @@ -0,0 +1,16 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +terraform { + required_providers { + local = { + source = "hashicorp/local" + } + } +} + +output "test_not_a_dir" { + # Known issue where relative path is based on where the test working directory is located: + # https://github.com/hashicorp/terraform-plugin-testing/issues/277 + value = provider::local::direxists("${path.module}/testdata/TestDirectoryExists_invalid_file/not_a_dir.txt") +} diff --git a/internal/provider/testdata/TestDirectoryExists_invalid_file/not_a_dir.txt b/internal/provider/testdata/TestDirectoryExists_invalid_file/not_a_dir.txt new file mode 100644 index 00000000..02103c6d --- /dev/null +++ b/internal/provider/testdata/TestDirectoryExists_invalid_file/not_a_dir.txt @@ -0,0 +1 @@ +This is a text file \ No newline at end of file diff --git a/internal/provider/testdata/TestDirectoryExists_invalid_symlink/main.tf b/internal/provider/testdata/TestDirectoryExists_invalid_symlink/main.tf new file mode 100644 index 00000000..ffcb089c --- /dev/null +++ b/internal/provider/testdata/TestDirectoryExists_invalid_symlink/main.tf @@ -0,0 +1,16 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +terraform { + required_providers { + local = { + source = "hashicorp/local" + } + } +} + +output "test_not_a_dir" { + # Known issue where relative path is based on where the test working directory is located: + # https://github.com/hashicorp/terraform-plugin-testing/issues/277 + value = provider::local::direxists("${path.module}/testdata/TestDirectoryExists_invalid_symlink/not_a_dir_symlink") +} diff --git a/internal/provider/testdata/TestDirectoryExists_invalid_symlink/not_a_dir_symlink b/internal/provider/testdata/TestDirectoryExists_invalid_symlink/not_a_dir_symlink new file mode 120000 index 00000000..f0fa8d6d --- /dev/null +++ b/internal/provider/testdata/TestDirectoryExists_invalid_symlink/not_a_dir_symlink @@ -0,0 +1 @@ +./test_dir/not_a_dir.txt \ No newline at end of file diff --git a/internal/provider/testdata/TestDirectoryExists_invalid_symlink/test_dir/not_a_dir.txt b/internal/provider/testdata/TestDirectoryExists_invalid_symlink/test_dir/not_a_dir.txt new file mode 100644 index 00000000..02103c6d --- /dev/null +++ b/internal/provider/testdata/TestDirectoryExists_invalid_symlink/test_dir/not_a_dir.txt @@ -0,0 +1 @@ +This is a text file \ No newline at end of file diff --git a/internal/provider/testdata/local_file b/internal/provider/testdata/TestLocalFileDataSource/local_file similarity index 100% rename from internal/provider/testdata/local_file rename to internal/provider/testdata/TestLocalFileDataSource/local_file diff --git a/internal/provider/testdata/TestLocalFileDataSource/main.tf b/internal/provider/testdata/TestLocalFileDataSource/main.tf new file mode 100644 index 00000000..4904e24b --- /dev/null +++ b/internal/provider/testdata/TestLocalFileDataSource/main.tf @@ -0,0 +1,8 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +data "local_file" "file" { + # Known issue where relative path is based on where the test working directory is located: + # https://github.com/hashicorp/terraform-plugin-testing/issues/277 + filename = "${path.module}/testdata/TestLocalFileDataSource/local_file" +} diff --git a/internal/provider/testdata/TestLocalFileSensitiveDataSource/local_file b/internal/provider/testdata/TestLocalFileSensitiveDataSource/local_file new file mode 100644 index 00000000..3cffd394 --- /dev/null +++ b/internal/provider/testdata/TestLocalFileSensitiveDataSource/local_file @@ -0,0 +1 @@ +This is some sensitive content \ No newline at end of file diff --git a/internal/provider/testdata/TestLocalFileSensitiveDataSource/main.tf b/internal/provider/testdata/TestLocalFileSensitiveDataSource/main.tf new file mode 100644 index 00000000..668999cf --- /dev/null +++ b/internal/provider/testdata/TestLocalFileSensitiveDataSource/main.tf @@ -0,0 +1,8 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +data "local_sensitive_file" "file" { + # Known issue where relative path is based on where the test working directory is located: + # https://github.com/hashicorp/terraform-plugin-testing/issues/277 + filename = "${path.module}/testdata/TestLocalFileSensitiveDataSource/local_file" +} diff --git a/templates/functions/direxists.md.tmpl b/templates/functions/direxists.md.tmpl new file mode 100644 index 00000000..d554d173 --- /dev/null +++ b/templates/functions/direxists.md.tmpl @@ -0,0 +1,30 @@ +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Summary | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Type}}: {{.Name}} + +{{ .Description | trimspace }} + +## Example Usage + +### Basic Usage + +{{ tffile "examples/functions/direxists/basic.tf" }} + +### Usage with home directory + +{{ tffile "examples/functions/direxists/homedir.tf" }} + +## Signature + +{{ .FunctionSignatureMarkdown }} + +## Arguments + +{{ .FunctionArgumentsMarkdown }} +{{ if .HasVariadic -}} +{{ .FunctionVariadicArgumentMarkdown }} +{{- end }} \ No newline at end of file