fix: temp file collisions (#235)

* fix: use temp directory for file conversions
* fix: update doc
---------

Signed-off-by: matttrach <matt.trachier@suse.com>
This commit is contained in:
Matt Trachier 2025-10-04 00:52:08 -05:00 committed by GitHub
parent 1d94e1ad26
commit 0b40f575e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 242 additions and 4 deletions

View File

@ -0,0 +1,19 @@
# Multiple Snapshot Use Case
This is an example of snapshotting the same file multiple times.
This show the same operations as the basic example,
but it shows that you can have multiple snapshots working in parallel on the same file without collisions.
# Updating the snapshot
To get the snapshot to update you can send in the "update" argument and change it.
The snapshot will update on that apply and remain static until the update argument is changed again.
# Base 64 Decode
Notice that the snapshot outputs use base64decode to return the actual file's value.
# Snapshots are Sensitive
You could achieve the goals of this resource using a terraform_data with some life-cycle options, except for this part.
The Snapshot resource's "snapshot" attribute is sensitive, this keeps sensitive or long files from being spewed into the logs.

View File

@ -0,0 +1,4 @@
terraform {
backend "local" {}
}

View File

@ -0,0 +1,30 @@
provider "file" {}
locals {
update = (var.update == "" ? "code-change-necessary" : var.update)
pesky_id = uuid()
name = var.name
directory = var.directory
count = 5
files = [for i in range(local.count) : format("%s_%d", local.name, i)]
}
# on first update the pesky_id and the snapshot will match
# on subsequent updates the snapshot will remain as the first id and the pesky_id will change
# when the update input is changed, then the snapshot will match again
resource "file_local" "snapshot_use_case_multiple" {
name = local.name
directory = local.directory
contents = local.pesky_id
}
resource "file_local_snapshot" "use_case_multiple" {
depends_on = [
file_local.snapshot_use_case_multiple,
]
for_each = toset(local.files)
name = local.name
directory = local.directory
update_trigger = local.update
}

View File

@ -0,0 +1,11 @@
output "pesky_id" {
value = local.pesky_id
}
output "snapshots" {
value = [
for s in file_local_snapshot.use_case_multiple : base64decode(s.snapshot)
]
sensitive = true
}

View File

@ -0,0 +1,15 @@
variable "update" {
type = string
default = "code-change-necessary"
}
variable "directory" {
type = string
default = "."
}
variable "name" {
type = string
default = "snapshot_resource_use_case_multiple.txt"
}

View File

@ -0,0 +1,11 @@
terraform {
required_version = ">= 1.5.0"
required_providers {
file = {
source = "rancher/file"
version = ">= 0.0.1"
}
}
}

1
go.mod
View File

@ -4,6 +4,7 @@ go 1.24.0
require (
github.com/google/go-cmp v0.7.0
github.com/google/uuid v1.6.0
github.com/hashicorp/terraform-plugin-framework v1.16.0
github.com/hashicorp/terraform-plugin-framework-validators v0.18.0
github.com/hashicorp/terraform-plugin-go v0.29.0

View File

@ -10,4 +10,5 @@ type FileClient interface {
Compress(directory string, name string, compressedName string) error
Encode(directory string, name string, encodedName string) error
Hash(directory string, name string) (string, error) // Sha256Hash, error
Copy(currentPath string, newPath string) error
}

View File

@ -7,6 +7,7 @@ import (
"encoding/base64"
"encoding/hex"
"fmt"
"path/filepath"
)
type MemoryFileClient struct {
@ -83,3 +84,9 @@ func (c *MemoryFileClient) Hash(directory string, name string) (string, error) {
return hashString, nil
}
func (c *MemoryFileClient) Copy(currentPath string, newPath string) error {
c.file["directory"] = filepath.Dir(newPath)
c.file["name"] = filepath.Base(newPath)
return nil
}

View File

@ -142,3 +142,24 @@ func (c *OsFileClient) Hash(directory string, name string) (string, error) {
hexContents := hex.EncodeToString(contentsHash)
return hexContents, nil
}
func (c *OsFileClient) Copy(currentPath string, newPath string) error {
srcFile, err := os.Open(currentPath)
if err != nil {
return err
}
defer srcFile.Close()
destFile, err := os.Create(newPath)
if err != nil {
return err
}
defer destFile.Close()
_, err = io.Copy(destFile, srcFile)
if err != nil {
return err
}
return nil
}

View File

@ -3,7 +3,10 @@ package file_local_snapshot
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/google/uuid"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
@ -132,9 +135,24 @@ func (r *LocalSnapshotResource) Create(ctx context.Context, req resource.CreateR
pDir := plan.Directory.ValueString()
pCompress := plan.Compress.ValueBool()
tempDir := filepath.Join(os.TempDir(), "terraform-provider-file", "snapshot", uuid.New().String())
err := os.MkdirAll(tempDir, os.ModePerm)
if err != nil {
resp.Diagnostics.AddError("Error creating temporary directory: ", err.Error())
return
}
defer os.RemoveAll(tempDir)
// copy the file to a temporary directory to prevent issues with encoding and compressing large files
err = r.client.Copy(filepath.Join(pDir, pName), filepath.Join(tempDir, pName))
if err != nil {
resp.Diagnostics.AddError("Error copying file to temporary directory: ", err.Error())
return
}
name := pName
if pCompress {
err := r.client.Compress(pDir, pName, "compressed_"+pName)
err := r.client.Compress(tempDir, pName, "compressed_"+pName)
if err != nil {
resp.Diagnostics.AddError("Error compressing file: ", err.Error())
return
@ -142,19 +160,19 @@ func (r *LocalSnapshotResource) Create(ctx context.Context, req resource.CreateR
name = "compressed_" + pName
}
err := r.client.Encode(pDir, name, "encoded_"+pName)
err = r.client.Encode(tempDir, name, "encoded_"+pName)
if err != nil {
resp.Diagnostics.AddError("Error encoding file: ", err.Error())
return
}
_, encodedContents, err := r.client.Read(pDir, "encoded_"+pName)
_, encodedContents, err := r.client.Read(tempDir, "encoded_"+pName)
if err != nil {
resp.Diagnostics.AddError("Error reading encoded file: ", err.Error())
return
}
plan.LocalSnapshot = types.StringValue(encodedContents)
hash, err := r.client.Hash(pDir, pName)
hash, err := r.client.Hash(tempDir, name)
if err != nil {
resp.Diagnostics.AddError("Error hashing file: ", err.Error())
return

View File

@ -0,0 +1,100 @@
package basic
import (
"path/filepath"
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
util "github.com/rancher/terraform-provider-file/test"
"github.com/stretchr/testify/assert"
)
func TestSnapshotMultipleBasic(t *testing.T) {
t.Parallel()
id := util.GetId()
directory := "local_snapshot_multiple"
repoRoot, err := util.GetRepoRoot(t)
if err != nil {
t.Fatalf("Error getting git root directory: %v", err)
}
exampleDir := filepath.Join(repoRoot, "examples", "use-cases", directory)
testDir := filepath.Join(repoRoot, "test", "data", id)
err = util.Setup(t, id, "test/data")
if err != nil {
t.Log("Test failed, tearing down...")
util.TearDown(t, testDir, &terraform.Options{})
t.Fatalf("Error creating test data directories: %s", err)
}
statePath := filepath.Join(testDir, "tfstate")
terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
TerraformDir: exampleDir,
Vars: map[string]interface{}{
"directory": testDir,
"name": "basic_test.txt",
},
BackendConfig: map[string]interface{}{
"path": statePath,
},
EnvVars: map[string]string{
"TF_DATA_DIR": testDir,
"TF_CLI_CONFIG_FILE": filepath.Join(repoRoot, "test", ".terraformrc"),
"TF_IN_AUTOMATION": "1",
"TF_CLI_ARGS_init": "-no-color",
"TF_CLI_ARGS_plan": "-no-color",
"TF_CLI_ARGS_apply": "-no-color",
"TF_CLI_ARGS_destroy": "-no-color",
"TF_CLI_ARGS_output": "-no-color",
},
RetryableTerraformErrors: util.GetRetryableTerraformErrors(),
NoColor: true,
Upgrade: true,
// ExtraArgs: terraform.ExtraArgs{ Output: []string{"-json"} },
})
_, err = terraform.InitAndApplyE(t, terraformOptions)
if err != nil {
t.Log("Test failed, tearing down...")
util.TearDown(t, testDir, terraformOptions)
t.Fatalf("Error creating file: %s", err)
}
outputs, err := terraform.OutputAllE(t, terraformOptions)
if err != nil {
t.Log("Output failed, moving along...")
}
pesky_id := outputs["pesky_id"]
snapshots, ok := outputs["snapshots"].([]interface{})
if !ok {
t.Fatalf("snapshots is not a []interface{}")
}
a := assert.New(t)
a.Equal(pesky_id, snapshots[0], "On the first run the snapshot will match the id.")
_, err = terraform.InitAndApplyE(t, terraformOptions)
if err != nil {
t.Log("Test failed, tearing down...")
util.TearDown(t, testDir, terraformOptions)
t.Fatalf("Error creating file: %s", err)
}
outputs, err = terraform.OutputAllE(t, terraformOptions)
if err != nil {
t.Log("Output failed, moving along...")
}
pesky_id = outputs["pesky_id"]
snapshots, ok = outputs["snapshots"].([]interface{})
if !ok {
t.Fatalf("snapshots is not a []interface{}")
}
a.NotEqual(pesky_id, snapshots[0], "On subsequent runs the id will change, but the snapshot won't.")
if t.Failed() {
t.Log("Test failed...")
} else {
t.Log("Test passed...")
}
t.Log("Test complete, tearing down...")
util.TearDown(t, testDir, terraformOptions)
}