hashicorp / hashicorp/terraform-plugin-framework
Empty `PathExpression` in Plan Modifiers breaks `path.MatchRelative()`
- 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
Assessment
This issue has not been assessed yet.