diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 8921670..634b181 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -76,7 +76,7 @@ jobs: matrix: # list whatever Terraform versions here you would like to support terraform: - - '1.8.*' + - '1.10.*' steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 diff --git a/.golangci.yml b/.golangci.yml index 904b175..e049f18 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -19,7 +19,7 @@ linters: - durationcheck - dupword - errcheck - - exportloopref + - copyloopvar - forcetypeassert - forbidigo - gci diff --git a/docs/ephemeral-resources/dotenv.md b/docs/ephemeral-resources/dotenv.md new file mode 100644 index 0000000..864eb27 --- /dev/null +++ b/docs/ephemeral-resources/dotenv.md @@ -0,0 +1,33 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "dotenv Ephemeral Resource - dotenv" +subcategory: "" +description: |- + Reads and provides all entries of a dotenv file. + All supported formats can be found here https://registry.terraform.io/providers/germanbrew/dotenv/latest/docs#supported-formats. + -> If you only need a specific value you can use the get_by_key https://registry.terraform.io/providers/germanbrew/dotenv/latest/docs/functions/get_by_key provider function. + Ephemeral resources are not stored in the state. +--- + +# dotenv (Ephemeral Resource) + +Reads and provides all entries of a dotenv file. + +All supported formats can be found [here](https://registry.terraform.io/providers/germanbrew/dotenv/latest/docs#supported-formats). + +-> If you only need a specific value you can use the [`get_by_key`](https://registry.terraform.io/providers/germanbrew/dotenv/latest/docs/functions/get_by_key) provider function. + +Ephemeral resources are not stored in the state. + + + + +## Schema + +### Optional + +- `filename` (String) `Default: .env` Path to the dotenv file + +### Read-Only + +- `entries` (Map of String) Key-Value entries of the dotenv file. diff --git a/internal/provider/file_ephemeral_resource.go b/internal/provider/file_ephemeral_resource.go new file mode 100644 index 0000000..7d76172 --- /dev/null +++ b/internal/provider/file_ephemeral_resource.go @@ -0,0 +1,112 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/germanbrew/terraform-provider-dotenv/internal/utils" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ ephemeral.EphemeralResource = &fileDotEnvEphemeralResource{} + +func NewFileDotEnvEphemeralResource() ephemeral.EphemeralResource { + return &fileDotEnvEphemeralResource{} +} + +// fileDotEnvEphemeralResource defines the data source implementation. +type fileDotEnvEphemeralResource struct{} + +// fileDotEnvEphemeralResourceModel describes the data source data model. +type fileDotEnvEphemeralResourceModel struct { + Filename types.String `tfsdk:"filename"` + Entries types.Map `tfsdk:"entries"` +} + +func (d *fileDotEnvEphemeralResource) Metadata(ctx context.Context, req ephemeral.MetadataRequest, resp *ephemeral.MetadataResponse) { + resp.TypeName = req.ProviderTypeName // + "_file" +} + +func (d *fileDotEnvEphemeralResource) Schema(ctx context.Context, req ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) { + resp.Schema = schema.Schema{ + // This description is used by the documentation generator and the language server. + MarkdownDescription: "Reads and provides all entries of a dotenv file.\n\n" + + "All supported formats can be found [here](https://registry.terraform.io/providers/germanbrew/dotenv/latest/docs#supported-formats).\n\n" + + "-> If you only need a specific value you can use the " + + "[`get_by_key`](https://registry.terraform.io/providers/germanbrew/dotenv/latest/docs/functions/get_by_key) provider function.\n\n" + + "Ephemeral resources are not stored in the state. ", + + Attributes: map[string]schema.Attribute{ + "filename": schema.StringAttribute{ + MarkdownDescription: "`Default: .env` Path to the dotenv file", + Optional: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "entries": schema.MapAttribute{ + MarkdownDescription: "Key-Value entries of the dotenv file.", + Computed: true, + ElementType: types.StringType, + }, + }, + } +} + +func (d *fileDotEnvEphemeralResource) Configure(ctx context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } +} + +func (d *fileDotEnvEphemeralResource) Open(ctx context.Context, req ephemeral.OpenRequest, resp *ephemeral.OpenResponse) { + var data fileDotEnvEphemeralResourceModel + + // Read Terraform configuration data into the model + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + filename := data.Filename.ValueString() + + if filename == "" { + filename = ".env" + tflog.Info(ctx, "No file name specified, so the default is used: "+filename) + } + + parsedEntries, err := utils.ParseDotEnvFile(filename) + if err != nil { + resp.Diagnostics.AddError("Parse Error", fmt.Sprintf("Parsing contents of file %s failed: %s", filename, err)) + + return + } + + entries := make(map[string]attr.Value, len(parsedEntries)) + for key, value := range parsedEntries { + entries[key] = types.StringValue(value) + + if err != nil { + resp.Diagnostics.AddError("Conversion Error", fmt.Sprintf("Failed to convert key %s value %s: %s", key, value, err)) + + return + } + } + + tflog.Debug(ctx, "Parsing the file was successful") + + data.Filename = types.StringValue(filename) + data.Entries, _ = types.MapValue(types.StringType, entries) + + // Save data into ephemeral result data + resp.Diagnostics.Append(resp.Result.Set(ctx, &data)...) +} diff --git a/internal/provider/file_ephemeral_resource_test.go b/internal/provider/file_ephemeral_resource_test.go new file mode 100644 index 0000000..0cf56a6 --- /dev/null +++ b/internal/provider/file_ephemeral_resource_test.go @@ -0,0 +1,128 @@ +package provider + +import ( + "fmt" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/hashicorp/terraform-plugin-testing/tfversion" +) + +func TestAccEphemeralResource_DotEnvFile_KnownKey(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_10_0), + }, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactoriesWithEcho, + Steps: []resource.TestStep{ + // Read testing + { + Config: testAccExampleEphemeralResourceConfig, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("echo.env", tfjsonpath.New("data").AtMapKey("EXAMPLE_STRING"), knownvalue.StringExact("Example v@lue!")), + statecheck.ExpectKnownValue("echo.env", tfjsonpath.New("data").AtMapKey("EXAMPLE_INT"), knownvalue.StringExact("100")), + statecheck.ExpectKnownValue("echo.env", tfjsonpath.New("data").AtMapKey("EXAMPLE_FLOAT"), knownvalue.StringExact("1.23")), + statecheck.ExpectKnownValue("echo.env", tfjsonpath.New("data").AtMapKey("SOME_VAR"), knownvalue.StringExact("someval")), + statecheck.ExpectKnownValue("echo.env", tfjsonpath.New("data").AtMapKey("BAR"), knownvalue.StringExact("BAZ")), + statecheck.ExpectKnownValue("echo.env", tfjsonpath.New("data").AtMapKey("FOO"), knownvalue.StringExact("BAR")), + statecheck.ExpectKnownValue("echo.env", tfjsonpath.New("data").AtMapKey("YAML_FOO"), knownvalue.StringExact("bar")), + }, + }, + }, + }) +} + +func TestAccEphemeralResource_DotEnvFile_UnknownKey(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_10_0), + }, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactoriesWithEcho, + Steps: []resource.TestStep{ + // Read testing + { + Config: testAccExampleEphemeralResourceConfig, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("echo.env", tfjsonpath.New("data").AtMapKey("unknown"), knownvalue.StringExact("invalid")), + }, + ExpectError: regexp.MustCompile(`path not found: specified key unknown not found in map at data.unknown`), + }, + }, + }) +} + +func TestAccEphemeralResource_DotEnvFile_UnknownFile(t *testing.T) { + resource.Test(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_10_0), + }, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactoriesWithEcho, + Steps: []resource.TestStep{ + // Read testing + { + Config: testAccUnknownEphemeralResourceConfig, + ExpectError: regexp.MustCompile(fmt.Sprintf("%s: %s", "testdata/unknown.env", ErrFileNotFound)), + }, + }, + }) +} + +func TestAccEphemeralResource_DotEnvFile_InvalidLine(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactoriesWithEcho, + Steps: []resource.TestStep{ + // Read testing + { + Config: testAccInvalidEphemeralResourceConfig, + ExpectError: regexp.MustCompile(fmt.Sprintf("%s: %s", ErrInvalidLine, "this is invalid")), + }, + }, + }) +} + +// lintignore:AT004 +const testAccExampleEphemeralResourceConfig = ` +ephemeral "dotenv" "test" { + filename = "./testdata/test.env" +} + +provider "echo" { + data = ephemeral.dotenv.test.entries +} + +resource "echo" "env" {} +` + +// lintignore:AT004 +const testAccUnknownEphemeralResourceConfig = ` +ephemeral "dotenv" "test" { + filename = "./testdata/unknown.env" +} + +provider "echo" { + data = ephemeral.dotenv.test.entries +} + +resource "echo" "env" {} +` + +// lintignore:AT004 +const testAccInvalidEphemeralResourceConfig = ` +ephemeral "dotenv" "test" { + filename = "./testdata/invalid.env" +} + +provider "echo" { + data = ephemeral.dotenv.test.entries +} + +resource "echo" "env" {} +` diff --git a/internal/provider/provider.go b/internal/provider/provider.go index a8438d6..d6b3f64 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -4,6 +4,7 @@ import ( "context" "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" "github.com/hashicorp/terraform-plugin-framework/function" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/schema" @@ -65,6 +66,12 @@ func (p *DotenvProvider) Resources(ctx context.Context) []func() resource.Resour return []func() resource.Resource{} } +func (p *DotenvProvider) EphemeralResources(ctx context.Context) []func() ephemeral.EphemeralResource { + return []func() ephemeral.EphemeralResource{ + NewFileDotEnvEphemeralResource, + } +} + func (p *DotenvProvider) DataSources(ctx context.Context) []func() datasource.DataSource { return []func() datasource.DataSource{ NewFileDotEnvDataSource, diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index b7431bc..eba99bd 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -8,6 +8,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/providerserver" "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-testing/echoprovider" ) // testAccProtoV6ProviderFactories are used to instantiate a provider during @@ -20,6 +21,12 @@ var testAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServe "dotenv": providerserver.NewProtocol6WithError(New("test")()), } +//nolint:gochecknoglobals +var testAccProtoV6ProviderFactoriesWithEcho = map[string]func() (tfprotov6.ProviderServer, error){ + "dotenv": providerserver.NewProtocol6WithError(New("test")()), + "echo": echoprovider.NewProviderServer(), +} + func testAccPreCheck(t *testing.T) { // You can add code here to run prior to any test case execution, for example assertions // about the appropriate environment variables being set are common to see in a pre-check