From 1ef2dda42e406b4a957e802847f6077e2eae3d55 Mon Sep 17 00:00:00 2001 From: Dan Barrett Date: Mon, 25 Aug 2025 15:12:28 +1000 Subject: [PATCH 1/6] wip: add ephemeral local_file resource --- docs/ephemeral-resources/file.md | 50 ++++ internal/provider/ephemeral_local_file.go | 277 ++++++++++++++++++ .../provider/ephemeral_local_file_test.go | 114 +++++++ internal/provider/provider.go | 10 +- internal/provider/provider_test.go | 8 + 5 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 docs/ephemeral-resources/file.md create mode 100644 internal/provider/ephemeral_local_file.go create mode 100644 internal/provider/ephemeral_local_file_test.go diff --git a/docs/ephemeral-resources/file.md b/docs/ephemeral-resources/file.md new file mode 100644 index 00000000..202789d0 --- /dev/null +++ b/docs/ephemeral-resources/file.md @@ -0,0 +1,50 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "local_file Ephemeral Resource - terraform-provider-local" +subcategory: "" +description: |- + Generates an ephemeral local file with the given content. +--- + +# local_file (Ephemeral Resource) + +Generates an ephemeral local file with the given content. + + + + +## Schema + +### Required + +- `filename` (String) The path to the file that will be created. + Missing parent directories will be created. + If the file already exists, it will be overridden with the given content. + +### Optional + +- `content` (String) Content to store in the file, expected to be a UTF-8 encoded string. + Conflicts with `content_base64` and `source`. + Exactly one of these three arguments must be specified. +- `content_base64` (String) Content to store in the file, expected to be binary encoded as base64 string. + Conflicts with `content` and `source`. + Exactly one of these three arguments must be specified. +- `directory_permission` (String) Permissions to set for directories created (before umask), expressed as string in + [numeric notation](https://en.wikipedia.org/wiki/File-system_permissions#Numeric_notation). + Default value is `"0777"`. +- `file_permission` (String) Permissions to set for the output file (before umask), expressed as string in + [numeric notation](https://en.wikipedia.org/wiki/File-system_permissions#Numeric_notation). + Default value is `"0777"`. +- `source` (String) Path to file to use as source for the one we are creating. + Conflicts with `content` and `content_base64`. + Exactly one of these three arguments must be specified. + +### Read-Only + +- `content_base64sha256` (String) Base64 encoded SHA256 checksum of file content. +- `content_base64sha512` (String) Base64 encoded SHA512 checksum of file content. +- `content_md5` (String) MD5 checksum of file content. +- `content_sha1` (String) SHA1 checksum of file content. +- `content_sha256` (String) SHA256 checksum of file content. +- `content_sha512` (String) SHA512 checksum of file content. +- `id` (String) The hexadecimal encoding of the SHA1 checksum of the file content. diff --git a/internal/provider/ephemeral_local_file.go b/internal/provider/ephemeral_local_file.go new file mode 100644 index 00000000..5a94648e --- /dev/null +++ b/internal/provider/ephemeral_local_file.go @@ -0,0 +1,277 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/terraform-providers/terraform-provider-local/internal/localtypes" +) + +var ( + _ ephemeral.EphemeralResource = (*localFileEphemeralResource)(nil) +) + +func NewLocalFileEphemeralResource() ephemeral.EphemeralResource { + return &localFileEphemeralResource{} +} + +type localFileEphemeralResource struct{} + +func (e *localFileEphemeralResource) Schema(_ context.Context, _ ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Generates an ephemeral local file with the given content.", + Attributes: map[string]schema.Attribute{ + "filename": schema.StringAttribute{ + Description: "The path to the file that will be created.\n " + + "Missing parent directories will be created.\n " + + "If the file already exists, it will be overridden with the given content.", + Required: true, + }, + "content": schema.StringAttribute{ + Description: "Content to store in the file, expected to be a UTF-8 encoded string.\n " + + "Conflicts with `content_base64` and `source`.\n " + + "Exactly one of these three arguments must be specified.", + Optional: true, + Validators: []validator.String{ + stringvalidator.ExactlyOneOf( + path.MatchRoot("content_base64"), + path.MatchRoot("source")), + }, + }, + "content_base64": schema.StringAttribute{ + Description: "Content to store in the file, expected to be binary encoded as base64 string.\n " + + "Conflicts with `content` and `source`.\n " + + "Exactly one of these three arguments must be specified.", + Optional: true, + Validators: []validator.String{ + stringvalidator.ExactlyOneOf( + path.MatchRoot("content"), + path.MatchRoot("source")), + }, + }, + "source": schema.StringAttribute{ + Description: "Path to file to use as source for the one we are creating.\n " + + "Conflicts with `content` and `content_base64`.\n " + + "Exactly one of these three arguments must be specified.", + Optional: true, + Validators: []validator.String{ + stringvalidator.ExactlyOneOf( + path.MatchRoot("content"), + path.MatchRoot("content_base64")), + }, + }, + "file_permission": schema.StringAttribute{ + CustomType: localtypes.NewFilePermissionType(), + Description: "Permissions to set for the output file (before umask), expressed as string in\n " + + "[numeric notation](https://en.wikipedia.org/wiki/File-system_permissions#Numeric_notation).\n " + + "Default value is `\"0777\"`.", + Optional: true, + Computed: true, + // Default: stringdefault.StaticString("0777"), + }, + "directory_permission": schema.StringAttribute{ + CustomType: localtypes.NewFilePermissionType(), + Description: "Permissions to set for directories created (before umask), expressed as string in\n " + + "[numeric notation](https://en.wikipedia.org/wiki/File-system_permissions#Numeric_notation).\n " + + "Default value is `\"0777\"`.", + Optional: true, + Computed: true, + // Default: stringdefault.StaticString("0777"), + }, + "id": schema.StringAttribute{ + Description: "The hexadecimal encoding of the SHA1 checksum of the file content.", + Computed: true, + }, + "content_md5": schema.StringAttribute{ + Description: "MD5 checksum of file content.", + Computed: true, + }, + "content_sha1": schema.StringAttribute{ + Description: "SHA1 checksum of file content.", + Computed: true, + }, + "content_sha256": schema.StringAttribute{ + Description: "SHA256 checksum of file content.", + Computed: true, + }, + "content_base64sha256": schema.StringAttribute{ + Description: "Base64 encoded SHA256 checksum of file content.", + Computed: true, + }, + "content_sha512": schema.StringAttribute{ + Description: "SHA512 checksum of file content.", + Computed: true, + }, + "content_base64sha512": schema.StringAttribute{ + Description: "Base64 encoded SHA512 checksum of file content.", + Computed: true, + }, + }, + } +} + +func (e *localFileEphemeralResource) Configure(_ context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) { + // +} + +func (e *localFileEphemeralResource) Metadata(ctx context.Context, req ephemeral.MetadataRequest, resp *ephemeral.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_file" // local_file +} + +func (e *localFileEphemeralResource) Open(ctx context.Context, req ephemeral.OpenRequest, resp *ephemeral.OpenResponse) { + fmt.Println("Start Open()") + var data localFileEphemeralResourceModelV0 + var filePerm, dirPerm string + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + content, err := parseEphemeralLocalFileContent(data) + if err != nil { + resp.Diagnostics.AddError( + "Create ephemeral local file error", + "An unexpected error occurred while parsing ephemeral local file content\n\n+"+ + fmt.Sprintf("Original Error: %s", err), + ) + return + } + + destination := data.Filename.ValueString() + // providerData["filename"] = []byte(destination) + // resp.Private.SetKey(ctx, "filename", []byte(destination)) + privateData, _ := json.Marshal(localFilePrivateData{Filename: destination}) + //resp.Private.SetKey(ctx, "file", []byte(fmt.Sprintf(`{"Filename": "%s"}`, destination))) + resp.Private.SetKey(ctx, "local_file_data", privateData) + + destinationDir := filepath.Dir(destination) + if _, err := os.Stat(destinationDir); err != nil { + dirPerm = data.DirectoryPermission.ValueString() + if dirPerm == "" { + dirPerm = "0777" + } + dirMode, _ := strconv.ParseInt(dirPerm, 8, 64) + if err := os.MkdirAll(destinationDir, os.FileMode(dirMode)); err != nil { + resp.Diagnostics.AddError( + "Create local file error", + "An unexpected error occurred while creating file directory\n\n+"+ + fmt.Sprintf("Original Error: %s", err), + ) + return + } + } + + filePerm = data.FilePermission.ValueString() + if filePerm == "" { + filePerm = "0777" + } + + fileMode, _ := strconv.ParseInt(filePerm, 8, 64) + + if err := os.WriteFile(destination, content, os.FileMode(fileMode)); err != nil { + resp.Diagnostics.AddError( + "Create local file error", + "An unexpected error occurred while writing the file\n\n+"+ + fmt.Sprintf("Original Error: %s", err), + ) + return + } + + fmt.Printf(" Created file: %s\n", destination) + + checksums := genFileChecksums(content) + data.ContentMd5 = types.StringValue(checksums.md5Hex) + data.ContentSha1 = types.StringValue(checksums.sha1Hex) + data.ContentSha256 = types.StringValue(checksums.sha256Hex) + data.ContentBase64sha256 = types.StringValue(checksums.sha256Base64) + data.ContentSha512 = types.StringValue(checksums.sha512Hex) + data.ContentBase64sha512 = types.StringValue(checksums.sha512Base64) + + data.ID = types.StringValue(checksums.sha1Hex) + resp.Diagnostics.Append(resp.Result.Set(ctx, &data)...) + fmt.Println("End Open()") +} + +func (e *localFileEphemeralResource) Close(ctx context.Context, req ephemeral.CloseRequest, resp *ephemeral.CloseResponse) { + fmt.Println("Start Close()") + // Destroy the file + file, err := req.Private.GetKey(ctx, "local_file_data") + + if err != nil { + fmt.Println("err", err) + return + } + + var config localFilePrivateData + if err := json.Unmarshal(file, &config); err != nil { + fmt.Println("unmarshal err", err) + return + } + + fmt.Printf("file config: %+v\n", config) + + if config.Filename != "" { + stat, err := os.Stat(config.Filename) + + if err != nil { + fmt.Println("stat err", err) + } + fmt.Printf("File exists: %t\n", !os.IsNotExist(err)) + if stat != nil { + fmt.Printf("File size: %d bytes\n", stat.Size()) + } + fmt.Printf("Deleting file: %s\n", config.Filename) + os.Remove(config.Filename) + } + fmt.Println("End Close()") + fmt.Println("============") +} + +func parseEphemeralLocalFileContent(data localFileEphemeralResourceModelV0) ([]byte, error) { + if !data.ContentBase64.IsNull() && !data.ContentBase64.IsUnknown() { + return base64.StdEncoding.DecodeString(data.ContentBase64.ValueString()) + } + + if !data.Source.IsNull() && !data.Source.IsUnknown() { + sourceFileContent := data.Source.ValueString() + return os.ReadFile(sourceFileContent) + } + + content := data.Content.ValueString() + return []byte(content), nil +} + +type localFileEphemeralResourceModelV0 struct { + Filename types.String `tfsdk:"filename"` + Content types.String `tfsdk:"content"` + ContentBase64 types.String `tfsdk:"content_base64"` + Source types.String `tfsdk:"source"` + FilePermission localtypes.FilePermissionValue `tfsdk:"file_permission"` + DirectoryPermission localtypes.FilePermissionValue `tfsdk:"directory_permission"` + ID types.String `tfsdk:"id"` + ContentMd5 types.String `tfsdk:"content_md5"` + ContentSha1 types.String `tfsdk:"content_sha1"` + ContentSha256 types.String `tfsdk:"content_sha256"` + ContentBase64sha256 types.String `tfsdk:"content_base64sha256"` + ContentSha512 types.String `tfsdk:"content_sha512"` + ContentBase64sha512 types.String `tfsdk:"content_base64sha512"` +} + +type localFilePrivateData struct { + Filename string `json:"filename"` +} diff --git a/internal/provider/ephemeral_local_file_test.go b/internal/provider/ephemeral_local_file_test.go new file mode 100644 index 00000000..534eb6fd --- /dev/null +++ b/internal/provider/ephemeral_local_file_test.go @@ -0,0 +1,114 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "fmt" + "path/filepath" + "strings" + "testing" + + r "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" +) + +func TestEphemeralLocalFile_Basic_FileContent(t *testing.T) { + f := filepath.Join(t.TempDir(), "local_file") + f = strings.ReplaceAll(f, `\`, `\\`) + + r.UnitTest(t, r.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []r.TestStep{ + { + Config: testAccConfigEphemeralLocalFileContent("This is some content", f), + ConfigStateChecks: []statecheck.StateCheck{ + // statecheck.ExpectKnownValue("echo.local_file", tfjsonpath.New("data").AtMapKey("value").AtMapKey("content"), knownvalue.StringExact("This is some content")), + statecheck.ExpectKnownValue("echo.local_file", tfjsonpath.New("data").AtMapKey("content"), knownvalue.StringExact("This is some content")), + }, + Check: checkFileDeleted(f), + }, + // { + // Config: testAccConfigEphemeralLocalFileEncodedBase64Content("VGhpcyBpcyBzb21lIG1vcmUgY29udGVudAo=", f), + // ConfigStateChecks: []statecheck.StateCheck{ + // statecheck.ExpectKnownValue("echo.local_file", tfjsonpath.New("data").AtMapKey("content"), knownvalue.StringExact("This is some more content")), + // }, + // Check: checkFileDeleted(f), + // }, + // { + // Config: testAccConfigEphemeralLocalFileDecodedBase64Content("This is some base64 content", f), + // Check: checkFileCreation("local_file_resource.test", f), + // }, + }, + CheckDestroy: checkFileDeleted(f), + }) +} + +func TestEphemeralLocalFile_Basic_EncodedBase64Content(t *testing.T) { + f := filepath.Join(t.TempDir(), "local_file") + f = strings.ReplaceAll(f, `\`, `\\`) + + r.UnitTest(t, r.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []r.TestStep{ + { + Config: testAccConfigEphemeralLocalFileEncodedBase64Content("VGhpcyBpcyBzb21lIG1vcmUgY29udGVudAo=", f), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("echo.local_file", tfjsonpath.New("data").AtMapKey("content_base64"), knownvalue.StringExact("VGhpcyBpcyBzb21lIG1vcmUgY29udGVudAo=")), + }, + Check: checkFileDeleted(f), + }, + }, + CheckDestroy: checkFileDeleted(f), + }) +} + +// func testAccConfigEphemeralLocalSourceFile(source, filename string) string { +// return fmt.Sprintf(` +// ephemeral "local_file" "file" { +// source = %[1]q +// filename = %[2]q +// }`, source, filename) +// } + +func testAccConfigEphemeralLocalFileContent(content, filename string) string { + return fmt.Sprintf(` +ephemeral "local_file" "file" { + content = %[1]q + filename = %[2]q +} + +provider "echo" { + data = ephemeral.local_file.file +} + +resource "echo" "local_file" {} +`, content, filename) +} + +func testAccConfigEphemeralLocalFileEncodedBase64Content(content, filename string) string { + return fmt.Sprintf(` +ephemeral "local_file" "file" { + content_base64 = %[1]q + filename = %[2]q +} + +provider "echo" { + data = ephemeral.local_file.file +} + +resource "echo" "local_file" {} +`, content, filename) +} + +// func testAccConfigEphemeralLocalFileDecodedBase64Content(content, filename string) string { +// return fmt.Sprintf(` +// ephemeral "local_file" "file" { +// content_base64 = base64encode(%[1]q) +// filename = %[2]q +// }`, content, filename) +// } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 907ce2f3..b9576f04 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -13,6 +13,7 @@ import ( "encoding/hex" "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" @@ -20,7 +21,8 @@ import ( ) var ( - _ provider.ProviderWithFunctions = (*localProvider)(nil) + _ provider.ProviderWithFunctions = (*localProvider)(nil) + _ provider.ProviderWithEphemeralResources = (*localProvider)(nil) ) func New() provider.Provider { @@ -51,6 +53,12 @@ func (p *localProvider) Resources(ctx context.Context) []func() resource.Resourc } } +func (p *localProvider) EphemeralResources(_ context.Context) []func() ephemeral.EphemeralResource { + return []func() ephemeral.EphemeralResource{ + NewLocalFileEphemeralResource, + } +} + func (p *localProvider) Functions(ctx context.Context) []func() function.Function { return []func() function.Function{ NewDirectoryExistsFunction, diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index e77983e3..6553cd35 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -11,6 +11,8 @@ import ( "github.com/hashicorp/terraform-plugin-framework/providerserver" "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-testing/echoprovider" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" ) @@ -21,6 +23,12 @@ func protoV5ProviderFactories() map[string]func() (tfprotov5.ProviderServer, err } } +func protoV6ProviderFactories() map[string]func() (tfprotov6.ProviderServer, error) { + return map[string]func() (tfprotov6.ProviderServer, error){ + "echo": echoprovider.NewProviderServer(), + } +} + func providerVersion233() map[string]resource.ExternalProvider { return map[string]resource.ExternalProvider{ "local": { From 1f7e992e1dd7073431209d486b98f63fcd05387d Mon Sep 17 00:00:00 2001 From: Dan Barrett Date: Mon, 25 Aug 2025 21:31:12 +1000 Subject: [PATCH 2/6] wip: cleanup and improved tests --- internal/provider/ephemeral_local_file.go | 52 ++++---- .../provider/ephemeral_local_file_test.go | 117 ++++++++++++++---- 2 files changed, 113 insertions(+), 56 deletions(-) diff --git a/internal/provider/ephemeral_local_file.go b/internal/provider/ephemeral_local_file.go index 5a94648e..b619213d 100644 --- a/internal/provider/ephemeral_local_file.go +++ b/internal/provider/ephemeral_local_file.go @@ -18,6 +18,8 @@ import ( "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/terraform-providers/terraform-provider-local/internal/localtypes" ) @@ -81,6 +83,7 @@ func (e *localFileEphemeralResource) Schema(_ context.Context, _ ephemeral.Schem "Default value is `\"0777\"`.", Optional: true, Computed: true, + // Can't set a default value for ephemeral resources, this is here as a fingers-crossed placeholder. // Default: stringdefault.StaticString("0777"), }, "directory_permission": schema.StringAttribute{ @@ -90,6 +93,7 @@ func (e *localFileEphemeralResource) Schema(_ context.Context, _ ephemeral.Schem "Default value is `\"0777\"`.", Optional: true, Computed: true, + // Can't set a default value for ephemeral resources, this is here as a fingers-crossed placeholder. // Default: stringdefault.StaticString("0777"), }, "id": schema.StringAttribute{ @@ -133,7 +137,6 @@ func (e *localFileEphemeralResource) Metadata(ctx context.Context, req ephemeral } func (e *localFileEphemeralResource) Open(ctx context.Context, req ephemeral.OpenRequest, resp *ephemeral.OpenResponse) { - fmt.Println("Start Open()") var data localFileEphemeralResourceModelV0 var filePerm, dirPerm string @@ -153,10 +156,7 @@ func (e *localFileEphemeralResource) Open(ctx context.Context, req ephemeral.Ope } destination := data.Filename.ValueString() - // providerData["filename"] = []byte(destination) - // resp.Private.SetKey(ctx, "filename", []byte(destination)) privateData, _ := json.Marshal(localFilePrivateData{Filename: destination}) - //resp.Private.SetKey(ctx, "file", []byte(fmt.Sprintf(`{"Filename": "%s"}`, destination))) resp.Private.SetKey(ctx, "local_file_data", privateData) destinationDir := filepath.Dir(destination) @@ -165,6 +165,8 @@ func (e *localFileEphemeralResource) Open(ctx context.Context, req ephemeral.Ope if dirPerm == "" { dirPerm = "0777" } + dirPermData := localtypes.FilePermissionValue{StringValue: basetypes.NewStringValue(dirPerm)} + data.DirectoryPermission = dirPermData dirMode, _ := strconv.ParseInt(dirPerm, 8, 64) if err := os.MkdirAll(destinationDir, os.FileMode(dirMode)); err != nil { resp.Diagnostics.AddError( @@ -180,6 +182,8 @@ func (e *localFileEphemeralResource) Open(ctx context.Context, req ephemeral.Ope if filePerm == "" { filePerm = "0777" } + filePermData := localtypes.FilePermissionValue{StringValue: basetypes.NewStringValue(filePerm)} + data.FilePermission = filePermData fileMode, _ := strconv.ParseInt(filePerm, 8, 64) @@ -192,7 +196,7 @@ func (e *localFileEphemeralResource) Open(ctx context.Context, req ephemeral.Ope return } - fmt.Printf(" Created file: %s\n", destination) + tflog.Debug(ctx, fmt.Sprintf("Created ephemeral file with name: %s", destination)) checksums := genFileChecksums(content) data.ContentMd5 = types.StringValue(checksums.md5Hex) @@ -204,42 +208,30 @@ func (e *localFileEphemeralResource) Open(ctx context.Context, req ephemeral.Ope data.ID = types.StringValue(checksums.sha1Hex) resp.Diagnostics.Append(resp.Result.Set(ctx, &data)...) - fmt.Println("End Open()") } func (e *localFileEphemeralResource) Close(ctx context.Context, req ephemeral.CloseRequest, resp *ephemeral.CloseResponse) { - fmt.Println("Start Close()") // Destroy the file - file, err := req.Private.GetKey(ctx, "local_file_data") - - if err != nil { - fmt.Println("err", err) + privateBytes, diags := req.Private.GetKey(ctx, "local_file_data") + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { return } - var config localFilePrivateData - if err := json.Unmarshal(file, &config); err != nil { - fmt.Println("unmarshal err", err) + var privateData localFilePrivateData + if err := json.Unmarshal(privateBytes, &privateData); err != nil { + resp.Diagnostics.AddError( + "Private data unmarshal error", + "An unexpected error occurred while unmarshaling private data\n\n+"+ + fmt.Sprintf("Original Error: %s", err), + ) return } - fmt.Printf("file config: %+v\n", config) - - if config.Filename != "" { - stat, err := os.Stat(config.Filename) - - if err != nil { - fmt.Println("stat err", err) - } - fmt.Printf("File exists: %t\n", !os.IsNotExist(err)) - if stat != nil { - fmt.Printf("File size: %d bytes\n", stat.Size()) - } - fmt.Printf("Deleting file: %s\n", config.Filename) - os.Remove(config.Filename) + if privateData.Filename != "" { + tflog.Debug(ctx, fmt.Sprintf("Deleting ephemeral file: %s", privateData.Filename)) + os.Remove(privateData.Filename) } - fmt.Println("End Close()") - fmt.Println("============") } func parseEphemeralLocalFileContent(data localFileEphemeralResourceModelV0) ([]byte, error) { diff --git a/internal/provider/ephemeral_local_file_test.go b/internal/provider/ephemeral_local_file_test.go index 534eb6fd..597b07b1 100644 --- a/internal/provider/ephemeral_local_file_test.go +++ b/internal/provider/ephemeral_local_file_test.go @@ -13,35 +13,60 @@ import ( "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 TestEphemeralLocalFile_Basic_SourceContent(t *testing.T) { + sourceDirPath := t.TempDir() + sourceFilePath := filepath.Join(sourceDirPath, "source_file") + sourceFilePath = strings.ReplaceAll(sourceFilePath, `\`, `\\`) + // create a local file that will be used as the "source" file + if err := createSourceFile(sourceFilePath, "local file content"); err != nil { + t.Fatal(err) + } + + destinationDirPath := t.TempDir() + destinationFilePath := filepath.Join(destinationDirPath, "new_file") + destinationFilePath = strings.ReplaceAll(destinationFilePath, `\`, `\\`) + + r.UnitTest(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_10_0), + }, + ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []r.TestStep{ + { + Config: testAccConfigEphemeralLocalSourceFile(sourceFilePath, destinationFilePath), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("echo.local_file", tfjsonpath.New("data").AtMapKey("source"), knownvalue.StringExact(sourceFilePath)), + // statecheck.ExpectKnownOutputValue("local_file", knownvalue.StringExact("local file content")), + }, + Check: checkFileDeleted(destinationFilePath), + }, + }, + CheckDestroy: checkFileDeleted(destinationFilePath), + }) +} + func TestEphemeralLocalFile_Basic_FileContent(t *testing.T) { f := filepath.Join(t.TempDir(), "local_file") f = strings.ReplaceAll(f, `\`, `\\`) r.UnitTest(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_10_0), + }, ProtoV5ProviderFactories: protoV5ProviderFactories(), ProtoV6ProviderFactories: protoV6ProviderFactories(), Steps: []r.TestStep{ { Config: testAccConfigEphemeralLocalFileContent("This is some content", f), ConfigStateChecks: []statecheck.StateCheck{ - // statecheck.ExpectKnownValue("echo.local_file", tfjsonpath.New("data").AtMapKey("value").AtMapKey("content"), knownvalue.StringExact("This is some content")), statecheck.ExpectKnownValue("echo.local_file", tfjsonpath.New("data").AtMapKey("content"), knownvalue.StringExact("This is some content")), }, Check: checkFileDeleted(f), }, - // { - // Config: testAccConfigEphemeralLocalFileEncodedBase64Content("VGhpcyBpcyBzb21lIG1vcmUgY29udGVudAo=", f), - // ConfigStateChecks: []statecheck.StateCheck{ - // statecheck.ExpectKnownValue("echo.local_file", tfjsonpath.New("data").AtMapKey("content"), knownvalue.StringExact("This is some more content")), - // }, - // Check: checkFileDeleted(f), - // }, - // { - // Config: testAccConfigEphemeralLocalFileDecodedBase64Content("This is some base64 content", f), - // Check: checkFileCreation("local_file_resource.test", f), - // }, }, CheckDestroy: checkFileDeleted(f), }) @@ -52,6 +77,9 @@ func TestEphemeralLocalFile_Basic_EncodedBase64Content(t *testing.T) { f = strings.ReplaceAll(f, `\`, `\\`) r.UnitTest(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_10_0), + }, ProtoV5ProviderFactories: protoV5ProviderFactories(), ProtoV6ProviderFactories: protoV6ProviderFactories(), Steps: []r.TestStep{ @@ -67,13 +95,43 @@ func TestEphemeralLocalFile_Basic_EncodedBase64Content(t *testing.T) { }) } -// func testAccConfigEphemeralLocalSourceFile(source, filename string) string { -// return fmt.Sprintf(` -// ephemeral "local_file" "file" { -// source = %[1]q -// filename = %[2]q -// }`, source, filename) -// } +func TestEphemeralLocalFile_Basic_DecodedBase64Content(t *testing.T) { + f := filepath.Join(t.TempDir(), "local_file") + f = strings.ReplaceAll(f, `\`, `\\`) + + r.UnitTest(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_10_0), + }, + ProtoV5ProviderFactories: protoV5ProviderFactories(), + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []r.TestStep{ + { + Config: testAccConfigEphemeralLocalFileDecodedBase64Content("This is some base64 content", f), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("echo.local_file", tfjsonpath.New("data").AtMapKey("content_base64"), knownvalue.StringExact("VGhpcyBpcyBzb21lIGJhc2U2NCBjb250ZW50")), + }, + Check: checkFileDeleted(f), + }, + }, + CheckDestroy: checkFileDeleted(f), + }) +} + +func testAccConfigEphemeralLocalSourceFile(source, filename string) string { + return fmt.Sprintf(` +ephemeral "local_file" "file" { + source = %[1]q + filename = %[2]q +} + +provider "echo" { + data = ephemeral.local_file.file +} + +resource "echo" "local_file" {} +`, source, filename) +} func testAccConfigEphemeralLocalFileContent(content, filename string) string { return fmt.Sprintf(` @@ -105,10 +163,17 @@ resource "echo" "local_file" {} `, content, filename) } -// func testAccConfigEphemeralLocalFileDecodedBase64Content(content, filename string) string { -// return fmt.Sprintf(` -// ephemeral "local_file" "file" { -// content_base64 = base64encode(%[1]q) -// filename = %[2]q -// }`, content, filename) -// } +func testAccConfigEphemeralLocalFileDecodedBase64Content(content, filename string) string { + return fmt.Sprintf(` +ephemeral "local_file" "file" { + content_base64 = base64encode(%[1]q) + filename = %[2]q +} + +provider "echo" { + data = ephemeral.local_file.file +} + +resource "echo" "local_file" {} +`, content, filename) +} From ddda4f98303f48d9dd87542f6eb5d5c09eb544c3 Mon Sep 17 00:00:00 2001 From: Dan Barrett Date: Mon, 25 Aug 2025 21:39:37 +1000 Subject: [PATCH 3/6] deps: move log to required imports --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index f18bbe54..b029c80b 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/hashicorp/terraform-plugin-framework v1.15.1 github.com/hashicorp/terraform-plugin-framework-validators v0.18.0 github.com/hashicorp/terraform-plugin-go v0.28.0 + github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/hashicorp/terraform-plugin-testing v1.13.3 ) @@ -32,7 +33,6 @@ require ( github.com/hashicorp/logutils v1.0.0 // indirect github.com/hashicorp/terraform-exec v0.23.0 // indirect github.com/hashicorp/terraform-json v0.25.0 // indirect - github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect github.com/hashicorp/terraform-plugin-sdk/v2 v2.37.0 // indirect github.com/hashicorp/terraform-registry-address v0.2.5 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect From 6ea5f869287a2268788fb5c66cde875b417d1531 Mon Sep 17 00:00:00 2001 From: Dan Barrett Date: Mon, 25 Aug 2025 22:32:20 +1000 Subject: [PATCH 4/6] docs: work on doco --- docs/ephemeral-resources/file.md | 51 ++++++++++++++++++- .../ephemeral-resource-file.tf | 32 ++++++++++++ templates/ephemeral-resources/file.md.tmpl | 29 +++++++++++ 3 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 examples/ephemeral-resources/ephemeral-resource-file.tf create mode 100644 templates/ephemeral-resources/file.md.tmpl diff --git a/docs/ephemeral-resources/file.md b/docs/ephemeral-resources/file.md index 202789d0..3aeaeb0c 100644 --- a/docs/ephemeral-resources/file.md +++ b/docs/ephemeral-resources/file.md @@ -1,5 +1,4 @@ --- -# generated by https://github.com/hashicorp/terraform-plugin-docs page_title: "local_file Ephemeral Resource - terraform-provider-local" subcategory: "" description: |- @@ -10,7 +9,55 @@ description: |- Generates an ephemeral local file with the given content. +-> **Note**: Ephemeral resources are available in Terraform v1.10 and later. +~> **Note about resource behaviour** +Ephemeral resources are considered to be sensitive so none of the arguments +can be displayed in output logs. This means if you use an ephemeral resource +in a provisioner the output will be suppressed. This cannot be overridden using +the `nonsensitive` function as while ephemeral values are considered sensitive +they are not actually sensitive values. + +~> **Note about file content** +File content must be specified with _exactly_ one of the arguments `content`, +`content_base64`, or `source`. + +## Example Usage + +```terraform +ephemeral "local_file" "foo" { + content = "foo!" + filename = "foo.bar" +} + +resource "terraform_data" "foo" { + provisioner "local-exec" { + command = "openssl sha256 ${ephemeral.local_file.foo.filename} > ${ephemeral.local_file.foo.filename}.sha256" + } +} + +locals { + filename_b64 = base64encode(ephemeral.local_file.foo.filename) + local_filename = nonsensitive(ephemeral.local_file.foo.filename) + is_sensitive = issensitive(ephemeral.local_file.foo.filename) + + testing_list = [ephemeral.local_file.foo.filename] +} + +resource "terraform_data" "bar" { + provisioner "local-exec" { + command = "echo 'is_sensitive: ${local.is_sensitive}'" + } + + provisioner "local-exec" { + command = "echo ${local.testing_list[0]}" + + # environment = { + # LOCAL_FILENAME = local.filename_b64 + # } + } +} +``` ## Schema @@ -47,4 +94,4 @@ Generates an ephemeral local file with the given content. - `content_sha1` (String) SHA1 checksum of file content. - `content_sha256` (String) SHA256 checksum of file content. - `content_sha512` (String) SHA512 checksum of file content. -- `id` (String) The hexadecimal encoding of the SHA1 checksum of the file content. +- `id` (String) The hexadecimal encoding of the SHA1 checksum of the file content. \ No newline at end of file diff --git a/examples/ephemeral-resources/ephemeral-resource-file.tf b/examples/ephemeral-resources/ephemeral-resource-file.tf new file mode 100644 index 00000000..00024f3f --- /dev/null +++ b/examples/ephemeral-resources/ephemeral-resource-file.tf @@ -0,0 +1,32 @@ +ephemeral "local_file" "foo" { + content = "foo!" + filename = "foo.bar" +} + +resource "terraform_data" "foo" { + provisioner "local-exec" { + command = "openssl sha256 ${ephemeral.local_file.foo.filename} > ${ephemeral.local_file.foo.filename}.sha256" + } +} + +locals { + filename_b64 = base64encode(ephemeral.local_file.foo.filename) + local_filename = nonsensitive(ephemeral.local_file.foo.filename) + is_sensitive = issensitive(ephemeral.local_file.foo.filename) + + testing_list = [ephemeral.local_file.foo.filename] +} + +resource "terraform_data" "bar" { + provisioner "local-exec" { + command = "echo 'is_sensitive: ${local.is_sensitive}'" + } + + provisioner "local-exec" { + command = "echo ${local.testing_list[0]}" + + # environment = { + # LOCAL_FILENAME = local.filename_b64 + # } + } +} \ No newline at end of file diff --git a/templates/ephemeral-resources/file.md.tmpl b/templates/ephemeral-resources/file.md.tmpl new file mode 100644 index 00000000..3373fadc --- /dev/null +++ b/templates/ephemeral-resources/file.md.tmpl @@ -0,0 +1,29 @@ +--- +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + +-> **Note**: Ephemeral resources are available in Terraform v1.10 and later. + +~> **Note about resource behaviour** +Ephemeral resources are considered to be sensitive so none of the arguments +can be displayed in output logs. This means if you use an ephemeral resource +in a provisioner the output will be suppressed. This cannot be overridden using +the `nonsensitive` function as while ephemeral values are considered sensitive +they are not actually sensitive values. + +~> **Note about file content** +File content must be specified with _exactly_ one of the arguments `content`, +`content_base64`, or `source`. + +## Example Usage + +{{ tffile "examples/ephemeral-resources/ephemeral-resource-file.tf" }} + +{{ .SchemaMarkdown | trimspace }} \ No newline at end of file From e4ec2db589c7f9b1cc2f87d2f7c122725adf3aa7 Mon Sep 17 00:00:00 2001 From: Dan Barrett Date: Mon, 25 Aug 2025 22:38:05 +1000 Subject: [PATCH 5/6] wip: cleanup --- internal/provider/ephemeral_local_file.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/provider/ephemeral_local_file.go b/internal/provider/ephemeral_local_file.go index b619213d..e3f2a429 100644 --- a/internal/provider/ephemeral_local_file.go +++ b/internal/provider/ephemeral_local_file.go @@ -128,10 +128,6 @@ func (e *localFileEphemeralResource) Schema(_ context.Context, _ ephemeral.Schem } } -func (e *localFileEphemeralResource) Configure(_ context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) { - // -} - func (e *localFileEphemeralResource) Metadata(ctx context.Context, req ephemeral.MetadataRequest, resp *ephemeral.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_file" // local_file } From c6de64e61ef0a2c5d6d6d7e4a230a4611b56117a Mon Sep 17 00:00:00 2001 From: Dan Barrett Date: Mon, 25 Aug 2025 22:48:04 +1000 Subject: [PATCH 6/6] wip: cleanup example/doc --- docs/ephemeral-resources/file.md | 22 ------------------- .../ephemeral-resource-file.tf | 22 ------------------- 2 files changed, 44 deletions(-) diff --git a/docs/ephemeral-resources/file.md b/docs/ephemeral-resources/file.md index 3aeaeb0c..74faed03 100644 --- a/docs/ephemeral-resources/file.md +++ b/docs/ephemeral-resources/file.md @@ -35,28 +35,6 @@ resource "terraform_data" "foo" { command = "openssl sha256 ${ephemeral.local_file.foo.filename} > ${ephemeral.local_file.foo.filename}.sha256" } } - -locals { - filename_b64 = base64encode(ephemeral.local_file.foo.filename) - local_filename = nonsensitive(ephemeral.local_file.foo.filename) - is_sensitive = issensitive(ephemeral.local_file.foo.filename) - - testing_list = [ephemeral.local_file.foo.filename] -} - -resource "terraform_data" "bar" { - provisioner "local-exec" { - command = "echo 'is_sensitive: ${local.is_sensitive}'" - } - - provisioner "local-exec" { - command = "echo ${local.testing_list[0]}" - - # environment = { - # LOCAL_FILENAME = local.filename_b64 - # } - } -} ``` diff --git a/examples/ephemeral-resources/ephemeral-resource-file.tf b/examples/ephemeral-resources/ephemeral-resource-file.tf index 00024f3f..bccc4fde 100644 --- a/examples/ephemeral-resources/ephemeral-resource-file.tf +++ b/examples/ephemeral-resources/ephemeral-resource-file.tf @@ -8,25 +8,3 @@ resource "terraform_data" "foo" { command = "openssl sha256 ${ephemeral.local_file.foo.filename} > ${ephemeral.local_file.foo.filename}.sha256" } } - -locals { - filename_b64 = base64encode(ephemeral.local_file.foo.filename) - local_filename = nonsensitive(ephemeral.local_file.foo.filename) - is_sensitive = issensitive(ephemeral.local_file.foo.filename) - - testing_list = [ephemeral.local_file.foo.filename] -} - -resource "terraform_data" "bar" { - provisioner "local-exec" { - command = "echo 'is_sensitive: ${local.is_sensitive}'" - } - - provisioner "local-exec" { - command = "echo ${local.testing_list[0]}" - - # environment = { - # LOCAL_FILENAME = local.filename_b64 - # } - } -} \ No newline at end of file