hashicorp / hashicorp/terraform-plugin-framework

Empty `PathExpression` in Plan Modifiers breaks `path.MatchRelative()`

Open
#1,258 0 comments 0 reactions 0 assignees View on GitHub
bug
Dominant language
Go
Stars
384
Forks
107
Avg merge
3m
Merged PRs (30d)
1

Description

### Module version
```
github.com/hashicorp/terraform-plugin-framework v1.17.0
```

### Relevant provider source code
```go
package debugplanmodifier

import (
"context"
"fmt"

"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
)

// ============================================================================
// DEBUG PLAN MODIFIER - Minimal implementation to reproduce PathExpression bug
// ============================================================================

// debugUseStateForUnknownUnlessChanged is a minimal plan modifier that
// demonstrates the PathExpression bug when using MatchRelative paths.
func debugUseStateForUnknownUnlessChanged(expressions ...path.Expression) planmodifier.String {
return debugPlanModifier{
expressions: expressions,
}
}

type debugPlanModifier struct {
expressions []path.Expression
}

func (m debugPlanModifier) Description(_ context.Context) string {
return "Debug plan modifier to reproduce PathExpression bug"
}

func (m debugPlanModifier) MarkdownDescription(_ context.Context) string {
return "Debug plan modifier to reproduce PathExpression bug"
}

func (m debugPlanModifier) PlanModifyString(
ctx context.Context,
req planmodifier.StringRequest,
resp *planmodifier.StringResponse,
) {
// Skip if creating (no state yet)
if req.State.Raw.IsNull() {
return
}

// Skip if plan value is already known
if !req.PlanValue.IsUnknown() {
return
}

// Skip if config value is unknown
if req.ConfigValue.IsUnknown() {
return
}

// DEBUG: Print the PathExpression - this is the bug!
// Expected: "computed_field" (the attribute this modifier is attached to)
// Actual: "" (empty string)
resp.Diagnostics.AddError(
fmt.Sprintf("DEBUG: request.PathExpression = '%v'", req.PathExpression),
"This shows the PathExpression is empty when it should contain the attribute path",
)

// MergeExpressions needs a non-empty PathExpression to work with relative paths
// Since PathExpression is empty, this produces invalid paths like "<.trigger_field"
mergedExpressions := req.PathExpression.MergeExpressions(m.expressions...)

resp.Diagnostics.AddError(
fmt.Sprintf("DEBUG: merged expressions = '%v'", mergedExpressions),
"This shows the malformed merged expressions due to empty PathExpression",
)

// Try to match paths (this will fail due to invalid path expression)
for _, expression := range mergedExpressions {
matchedPaths, diags := req.Config.PathMatches(ctx, expression)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}

for _, mp := range matchedPaths {
var stateVal, planVal attr.Value

resp.Diagnostics.Append(req.Plan.GetAttribute(ctx, mp, &planVal)...)
if resp.Diagnostics.HasError() {
return
}

if planVal.IsUnknown() {
continue
}

resp.Diagnostics.Append(req.State.GetAttribute(ctx, mp, &stateVal)...)
if resp.Diagnostics.HasError() {
return
}

// If the watched attribute changed, don't use state (let it be unknown)
if !stateVal.Equal(planVal) {
return
}
}
}

// Use state value if watched attributes haven't changed
resp.PlanValue = req.StateValue
}

// ============================================================================
// RESOURCE MODEL AND SCHEMA
// ============================================================================

type ResourceModel struct {
ID types.String `tfsdk:"id"`
Label types.String `tfsdk:"label"`
TriggerField types.String `tfsdk:"trigger_field"`
ComputedField types.String `tfsdk:"computed_field"`
}

var frameworkResourceSchema = schema.Schema{
Description: "Debug resource to reproduce PathExpression bug with MatchRelative in plan modifiers.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "The id of the resource.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"label": schema.StringAttribute{
Description: "A label for this resource.",
Required: true,
},
"trigger_field": schema.StringAttribute{
Description: "When this field changes, computed_field should become unknown.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"computed_field": schema.StringAttribute{
Description: "This field uses the debug plan modifier with MatchRelative path.",
Computed: true,
PlanModifiers: []planmodifier.String{
// BUG: request.PathExpression is empty when this modifier runs,
// causing MatchRelative paths to fail
debugUseStateForUnknownUnlessChanged(
path.MatchRelative().AtParent().AtName("trigger_field"),
),
},
},
},
}

func NewResource() resource.Resource {
return &Resource{}
}

type Resource struct{}

func (r *Resource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_debug_plan_modifier"
}

func (r *Resource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = frameworkResourceSchema
}

func (r *Resource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
// No provider configuration needed for this debug resource
}

func (r *Resource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}

func (r *Resource) Create(
ctx context.Context,
req resource.CreateRequest,
resp *resource.CreateResponse,
) {
var plan ResourceModel

resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}

// Simulate setting computed values
plan.ID = plan.Label
plan.ComputedField = plan.Label

resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}

func (r *Resource) Read(
ctx context.Context,
req resource.ReadRequest,
resp *resource.ReadResponse,
) {
var state ResourceModel

resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}

resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}

func (r *Resource) Update(
ctx context.Context,
req resource.UpdateRequest,
resp *resource.UpdateResponse,
) {
var plan, state ResourceModel

resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}

// Update computed field when trigger field changes
plan.ComputedField = plan.TriggerField

resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}

func (r *Resource) Delete(
ctx context.Context,
req resource.DeleteRequest,
resp *resource.DeleteResponse,
) {
// No-op for this debug resource
}

```

### Terraform Configuration Files
First:
```hcl
resource "linode_debug_plan_modifier" "test" {
label = "test-label"
trigger_field = "original-value"
}
```
Then:
```hcl
resource "linode_debug_plan_modifier" "test" {
label = "test-label"
trigger_field = "changed-value"
}
```

### Expected Behavior
The correct path expression of the attribute should be given in the request struct instance to the plan modifier.

### Actual Behavior
```

│ Error: DEBUG: request.PathExpression = ''

│ with linode_debug_plan_modifier.test,
│ on main.tf line 36, in resource "linode_debug_plan_modifier" "test":
│ 36: resource "linode_debug_plan_modifier" "test" {

│ This shows the PathExpression is empty when it should contain the attribute path


│ Error: DEBUG: merged expressions = '[<.trigger_field]'

│ with linode_debug_plan_modifier.test,
│ on main.tf line 36, in resource "linode_debug_plan_modifier" "test":
│ 36: resource "linode_debug_plan_modifier" "test" {

│ This shows the malformed merged expressions due to empty PathExpression


│ Error: Invalid Path Expression for Schema

│ with linode_debug_plan_modifier.test,
│ on main.tf line 36, in resource "linode_debug_plan_modifier" "test":
│ 36: resource "linode_debug_plan_modifier" "test" {

│ The Terraform Provider unexpectedly provided a path expression that does not match the current schema. This can happen if the
│ path expression does not correctly follow the schema in structure or types. Please report this to the provider developers.

│ Path Expression: <.trigger_field
```

### Steps to Reproduce
1. `terraform init`
2. `terraform apply` (with the first config)
3. `terraform apply` (with the second config)

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.