Plan modifer issues or issues with terraform-plugin-testing

Hey folks looking for some guidance.

I have custom provider, my team using for our services. it’s pretty straight forward → once you reference a file in it it upload it to our server. On of the property on schema is hash.
We calculate that hash base on content of the file(using bcrypt) the good part about this - it secure. The bad part → hash is alway different.
We have plan modifier for that particular property set like this:

"content_hash": schema.StringAttribute{
	Description:         "hash of the targeted entity ",
	MarkdownDescription: "hash of the targeted entity ",
	Computed:            true,
	PlanModifiers: []planmodifier.String{
		modifiers.HashPlanModifier(),
	},
},

I am running ACC test and they always failing. From debug logs I see following:

  1. tf sees initial state
  | 
  | resource "object" "test" {
  |   source = "/var/folders/2r/fwfvrrjd3sz52rqmqcb1j7240000gn/T/TestNewBucketObjectResource1025486755/001/testfileName"
  |   key = "acc_test_file"
  | }
  1. After it run modifier it becomes:
  | 
  | resource "object" "test" {
  |   source = "/var/folders/2r/fwfvrrjd3sz52rqmqcb1j7240000gn/T/TestNewBucketObjectResource1025486755/001/testfileName"
  |   key = "acc_test_file"
  |       + content_hash = "$2a$10$s8nFAv2L3J/8BsKzYI5DTuPSpvSgP47PY56uc7e3Ym9hR/iN5MHT2"
  | }
  1. Failure right after:
  | Error: Provider produced inconsistent final plan
  | 
  | When expanding the plan for object.test to include new
  | values learned so far during apply, provider
  | "registry.terraform.io/hashicorp/custom" produced an invalid new value for
  | .content_hash: was
  | cty.StringVal("$2a$10$s8nFAv2L3J/8BsKzYI5DTuPSpvSgP47PY56uc7e3Ym9hR/iN5MHT2"),
  | but now
  | cty.StringVal("$2a$10$EFVgrcWdjh61/5YyANOxpOrhN3UXziU3RS3OUoapBVoSu9MFqcOua").
  | 
  | This is a bug in the provider, which should be reported in the provider's own
  | issue tracker.

Hashing the same file shouldn’t produce different hash value. That’s the entire reason for hash after all!

I’d suggest investigate why you are getting different hashes instead.

That’s bcrypt implementation

package main

import (
    "fmt"

    "golang.org/x/crypto/bcrypt"
)

func HashPassword(password string) (string, error) {
    bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
    return string(bytes), err
}

func CheckPasswordHash(password, hash string) bool {
    err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
    return err == nil
}

func main() {
    password := "secret"
    hash, _ := HashPassword(password) // ignore error for the sake of simplicity

    fmt.Println("Password:", password)
    fmt.Println("Hash:    ", hash)

    match := CheckPasswordHash(password, hash)
    fmt.Println("Match:   ", match)
}

Bcrypt hashes are designed to be different every time, even for the same input. It incorporates a salt and a work factor (cost) into the hashing process. These features make it highly resistant to rainbow table attacks and brute force attacks. The drawback - hashes always different.

Hi @andrii-glukhyi :wave:

I’m wondering whether the HashPlanModifier that you are using on the content_hash string attribute is verifying whether the PlanValue for the attribute matches the value returned from CheckPasswordHash() before generating a new hash?

Could you share the code for the HashPlanModifier and the CRUD functions on the resource to provide a little more context?

Sure here is the logic for the plan modifier

func HashPlanModifier() planmodifier.String {
	return &hashAttributePlanModifier{}
}

type hashAttributePlanModifier struct {
}

func (d *hashAttributePlanModifier) Description(_ context.Context) string {
	return "Create unique hash of the object."
}

func (d *hashAttributePlanModifier) MarkdownDescription(ctx context.Context) string {
	return d.Description(ctx)
}

func (d *hashAttributePlanModifier) PlanModifyString(ctx context.Context,
	req planmodifier.StringRequest, resp *planmodifier.StringResponse) {
	var source types.String
	diags := req.Plan.GetAttribute(ctx, path.Root("source"), &source)
	resp.Diagnostics.Append(diags...)
	if resp.Diagnostics.HasError() {
		return
	}

	var content types.String
	req.Plan.GetAttribute(ctx, path.Root("content"), &content)
	resp.Diagnostics.Append(diags...)
	if resp.Diagnostics.HasError() {
		return
	}

	var contentBase64 types.String
	req.Plan.GetAttribute(ctx, path.Root("content_base64"), &contentBase64)
	resp.Diagnostics.Append(diags...)
	if resp.Diagnostics.HasError() {
		return
	}
	var data []byte
	if !source.IsNull() {
		var err error
		data, err = utility.CalculateChecksum(source.ValueString())
		if err != nil {
			resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Got following error reading source content: %v", err))
			return
		}
	} else if !content.IsNull() {
		data = []byte(content.ValueString())
	} else if !contentBase64.IsNull() {
		contentRaw, decodeErr := base64.StdEncoding.DecodeString(contentBase64.ValueString())
		if decodeErr != nil {
			resp.Diagnostics.AddError("Client Error",
				fmt.Sprintf("Got following error decoding base 64 content value: %s", decodeErr))
			return
		}
		data = contentRaw
	} else {
		data = []byte("")
	}

	hash, hashErr := utility.GenerateHash(data)
	if hashErr != nil {
		resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Got following error creating hash: %v", hashErr))
		return
	}
	planHashTheSame := utility.VerifyHash([]byte(req.PlanValue.ValueString()), data) == nil
	stateHashTheSame := utility.VerifyHash([]byte(req.StateValue.ValueString()), data) == nil
	newHash := types.StringValue(string(hash))
	if req.PlanValue.ValueString() == "" && req.StateValue.ValueString() == "" {
		// no plan and no state //
		// most likely first time //
		// set hash and do  not update //
		resp.PlanValue = types.StringValue(string(hash))
		resp.RequiresReplace = false
	} else if req.PlanValue.ValueString() != "" && req.StateValue.ValueString() == "" {
		// there is plan but no state //
		// most likely first plan command have been run //
		// if hash are correct don't do anything otherwise update
		// but since there is no state then no updates required
		if planHashTheSame {
			resp.PlanValue = req.PlanValue
		} else {
			resp.PlanValue = newHash
		}
		resp.RequiresReplace = false

	} else if req.PlanValue.ValueString() == "" && req.StateValue.ValueString() != "" {
		// is state present but no plan //
		// if state is correct then keep it //
		// otherwise set to new hash
		if stateHashTheSame {
			resp.RequiresReplace = false
			resp.PlanValue = req.StateValue
		} else {
			resp.RequiresReplace = true
			resp.PlanValue = newHash
		}
	} else {
		if (planHashTheSame && stateHashTheSame) || (!planHashTheSame && stateHashTheSame) {
			resp.PlanValue = req.StateValue
			resp.RequiresReplace = false
		} else if planHashTheSame && !stateHashTheSame {
			resp.PlanValue = req.PlanValue
			resp.RequiresReplace = true
		} else {
			resp.PlanValue = newHash
			resp.RequiresReplace = true
		}
	}
}```

I was debugging test. From debugging stand point I am hitting plan modification only once. I am not sure when and how another hash is getting created.

Hey @andrii-glukhyi :wave:

Thank you for sharing the plan modifier code. Is the source that is to be hashed being retrieved by an API call, or is it supplied as part of the Terraform configuration? The reason I ask is that if you are retrieving the source, then you could store some sort of identifier in the TF state to indicate whether this had been changed between sequential Terraform runs and use the identifier to trigger an update, or destroy-create.

I’m wondering whether the generation of the content_hash could be handled directly within the CRUD functions.

The following example assumes that source contains the actual content to be hashed:

func (e *exampleResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
	resp.Schema = schema.Schema{
		Attributes: map[string]schema.Attribute{
			"source": schema.StringAttribute{
				Required: true,
			},
			"content_hash": schema.StringAttribute{
				Description:         "hash of the targeted entity ",
				MarkdownDescription: "hash of the targeted entity ",
				Computed:            true,
				PlanModifiers: []planmodifier.String{
					stringplanmodifier.RequiresReplace(),
				},
			},
		},
	}
}

type exampleResourceData struct {
	Source      types.String `tfsdk:"source"`
	ContentHash types.String `tfsdk:"content_hash"`
}

func (e *exampleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
	var data exampleResourceData

	diags := req.Plan.Get(ctx, &data)

	resp.Diagnostics.Append(diags...)

	if resp.Diagnostics.HasError() {
		return
	}

	var source string

	diags = req.Plan.GetAttribute(ctx, path.Root("source"), &source)

	resp.Diagnostics.Append(diags...)

	if resp.Diagnostics.HasError() {
		return
	}

	hash, err := HashPassword(source)

	if err != nil {
		resp.Diagnostics.AddError("HashPassword error", err.Error())

		return
	}

	data.ContentHash = types.StringValue(hash)

	diags = resp.State.Set(ctx, &data)

	resp.Diagnostics.Append(diags...)
}

func (e *exampleResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
	var data exampleResourceData

	diags := req.Plan.Get(ctx, &data)
	resp.Diagnostics.Append(diags...)

	if resp.Diagnostics.HasError() {
		return
	}

	var source string

	diags = req.Plan.GetAttribute(ctx, path.Root("source"), &source)

	resp.Diagnostics.Append(diags...)

	if resp.Diagnostics.HasError() {
		return
	}

	if !CheckPasswordHash(source, data.ContentHash.ValueString()) {

		hash, err := HashPassword(source)

		if err != nil {
			resp.Diagnostics.AddError("HashPassword error", err.Error())

			return
		}

		data.ContentHash = types.StringValue(hash)
	}

	diags = resp.State.Set(ctx, &data)

	resp.Diagnostics.Append(diags...)
}