Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions docs/guides/v2.0-to-v2.1-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,67 @@ provider "hookdeck" {

- Automatic rate limiting to respect Hookdeck API limits (240 requests/minute)

## Troubleshooting

### Value Conversion Error with Connection Rules

If you encounter an error like this when upgrading:

```
Error: Value Conversion Error

An unexpected error was encountered trying to convert tftypes.Value into
connection.rule. This is always an error in the provider. Please report the
following to the provider developer:

mismatch between struct and object: Struct defines fields not found in
object: deduplicate_rule.
```

This error was fixed in a later provider release. To resolve it:

1. **Update to the latest provider version** in your Terraform configuration:
```hcl
terraform {
required_providers {
hookdeck = {
source = "hookdeck/hookdeck"
version = ">= 2.2.1" # Or the latest version
}
}
}
```

2. **Reinitialize Terraform** to download the updated provider:
```bash
terraform init -upgrade
```

3. **Run plan/apply** - the state upgrade will happen automatically:
```bash
terraform plan
terraform apply
```

#### Workaround (if you cannot update the provider)

If you need an immediate workaround without updating the provider:

1. **Remove the affected resource from state**:
```bash
terraform state rm hookdeck_connection.<your_connection_name>
```

2. **Import the resource with fresh state**:
```bash
terraform import hookdeck_connection.<your_connection_name> <connection-id>
```

You can find the connection ID in the Hookdeck dashboard or via the API.

3. **Verify with plan**:
```bash
terraform plan
```

The changes in this release are mostly improvements with minimal impact on existing configurations.
48 changes: 48 additions & 0 deletions internal/provider/connection/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,51 @@ type deduplicateRule struct {
IncludeFields types.List `tfsdk:"include_fields"`
ExcludeFields types.List `tfsdk:"exclude_fields"`
}

// V0 model structs for state upgrade compatibility
// These match the V0 schema which:
// - Did NOT have deduplicate_rule
// - Did NOT have response_status_codes in retry_rule
// - Used plain types.String for JSON in filter rule (not jsontypes.Normalized)

type connectionResourceModelV0 struct {
CreatedAt types.String `tfsdk:"created_at"`
Description types.String `tfsdk:"description"`
DestinationID types.String `tfsdk:"destination_id"`
DisabledAt types.String `tfsdk:"disabled_at"`
ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"`
PausedAt types.String `tfsdk:"paused_at"`
Rules []ruleV0 `tfsdk:"rules"`
SourceID types.String `tfsdk:"source_id"`
TeamID types.String `tfsdk:"team_id"`
UpdatedAt types.String `tfsdk:"updated_at"`
}

type ruleV0 struct {
DelayRule *delayRule `tfsdk:"delay_rule"`
FilterRule *filterRuleV0 `tfsdk:"filter_rule"`
RetryRule *retryRuleV0 `tfsdk:"retry_rule"`
TransformRule *transformRule `tfsdk:"transform_rule"`
}

type filterRuleV0 struct {
Body *filterRulePropertyV0 `tfsdk:"body"`
Headers *filterRulePropertyV0 `tfsdk:"headers"`
Path *filterRulePropertyV0 `tfsdk:"path"`
Query *filterRulePropertyV0 `tfsdk:"query"`
}

type filterRulePropertyV0 struct {
Boolean types.Bool `tfsdk:"boolean"`
JSON types.String `tfsdk:"json"` // V0 used plain string, not jsontypes.Normalized
Number types.Number `tfsdk:"number"`
String types.String `tfsdk:"string"`
}

type retryRuleV0 struct {
Count types.Int64 `tfsdk:"count"`
Interval types.Int64 `tfsdk:"interval"`
Strategy types.String `tfsdk:"strategy"`
// Note: V0 did NOT have response_status_codes
}
54 changes: 54 additions & 0 deletions internal/provider/connection/resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -437,3 +437,57 @@ func TestAccConnectionResourceFilterJSONFormatting(t *testing.T) {
},
})
}

// TestAccConnectionResource_Issue191 verifies the fix for issue #191.
// This test uses the exact configuration from the GitHub issue where users
// encountered "Value Conversion Error" with "Struct defines fields not found
// in object: deduplicate_rule" when configuring a connection with a retry rule.
// See: https://github.com/hookdeck/terraform-provider-hookdeck/issues/191
//
// Note: This acceptance test validates that the configuration from issue #191
// works correctly with the fixed provider, addressing the user's immediate problem.
// It creates a resource with the current provider version rather than testing
// the actual state upgrade scenario from V0 to V1 (which would require using
// ExternalProviders with an older provider version). The unit tests in
// stateupgrade_test.go provide comprehensive coverage of the state upgrade logic.
func TestAccConnectionResource_Issue191(t *testing.T) {
rName := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum)
resourceName := fmt.Sprintf("hookdeck_connection.test_%s", rName)

resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
// Step 1: Create connection with retry rule (exact config from issue #191)
{
Config: loadTestConfigFormatted("issue_191_retry_only.tf", rName),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr(resourceName, "name", fmt.Sprintf("test-connection-issue191-%s", rName)),
resource.TestCheckResourceAttr(resourceName, "rules.#", "1"),
resource.TestCheckResourceAttr(resourceName, "rules.0.retry_rule.strategy", "exponential"),
resource.TestCheckResourceAttr(resourceName, "rules.0.retry_rule.count", "6"),
resource.TestCheckResourceAttr(resourceName, "rules.0.retry_rule.interval", "60000"),
resource.TestCheckResourceAttr(resourceName, "rules.0.retry_rule.response_status_codes.#", "1"),
resource.TestCheckResourceAttr(resourceName, "rules.0.retry_rule.response_status_codes.0", "500-599"),
// Verify deduplicate_rule is not present (the field that caused the error)
resource.TestCheckNoResourceAttr(resourceName, "rules.0.deduplicate_rule"),
),
},
// Step 2: Import state to verify state handling
{
ResourceName: resourceName,
ImportState: true,
ImportStateVerify: true,
},
// Step 3: Re-apply the same config to verify no drift
{
Config: loadTestConfigFormatted("issue_191_retry_only.tf", rName),
PlanOnly: true,
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr(resourceName, "rules.#", "1"),
resource.TestCheckResourceAttr(resourceName, "rules.0.retry_rule.strategy", "exponential"),
),
},
},
})
}
125 changes: 112 additions & 13 deletions internal/provider/connection/stateupgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"context"
"sort"

"github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
)

var _ resource.ResourceWithUpgradeState = &connectionResource{}
Expand All @@ -19,27 +21,32 @@ func (r *connectionResource) UpgradeState(ctx context.Context) map[int64]resourc
Attributes: schemaAttributesV0(),
},
StateUpgrader: func(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) {
// Read the current state
var oldState connectionResourceModel
// Read the current state using V0 model
// This fixes issue #191: V0 schema doesn't have deduplicate_rule,
// so we must use a V0-compatible model struct
var oldState connectionResourceModelV0
resp.Diagnostics.Append(req.State.Get(ctx, &oldState)...)
if resp.Diagnostics.HasError() {
return
}

// Sort rules by type priority: Transform > Filter > Retry > Delay
if len(oldState.Rules) > 0 {
sort.Slice(oldState.Rules, func(i, j int) bool {
iPriority := getRulePriority(&oldState.Rules[i])
jPriority := getRulePriority(&oldState.Rules[j])
// Convert V0 model to current model
newState := convertV0ToCurrent(oldState)

// Sort rules by type priority: Transform > Filter > Deduplicate > Delay > Retry
if len(newState.Rules) > 0 {
sort.Slice(newState.Rules, func(i, j int) bool {
iPriority := getRulePriority(&newState.Rules[i])
jPriority := getRulePriority(&newState.Rules[j])
return iPriority < jPriority
})
}

// Set the upgraded state
resp.Diagnostics.Append(resp.State.Set(ctx, &oldState)...)
resp.Diagnostics.Append(resp.State.Set(ctx, &newState)...)

// Add a warning to inform the user
if len(oldState.Rules) > 0 {
if len(newState.Rules) > 0 {
resp.Diagnostics.AddWarning(
"Potential Action Required: Connection Rules Ordering",
"Hookdeck has migrated all existing connection rules to an ordered list format.\n"+
Expand All @@ -56,19 +63,111 @@ func (r *connectionResource) UpgradeState(ctx context.Context) map[int64]resourc
}
}

// getRulePriority returns the priority for sorting rules during migration: Transform > Filter > Delay > Retry.
// getRulePriority returns the priority for sorting rules during migration: Transform > Filter > Deduplicate > Delay > Retry.
func getRulePriority(r *rule) int {
if r.TransformRule != nil {
return 1
}
if r.FilterRule != nil {
return 2
}
if r.DelayRule != nil {
if r.DeduplicateRule != nil {
return 3
}
if r.RetryRule != nil {
if r.DelayRule != nil {
return 4
}
return 5 // Unknown rule type
if r.RetryRule != nil {
return 5
}
return 6 // Unknown rule type
}

// convertV0ToCurrent converts a V0 connection model to the current model.
// This is used during state upgrade from schema version 0 to version 1.
func convertV0ToCurrent(v0 connectionResourceModelV0) connectionResourceModel {
return connectionResourceModel{
CreatedAt: v0.CreatedAt,
Description: v0.Description,
DestinationID: v0.DestinationID,
DisabledAt: v0.DisabledAt,
ID: v0.ID,
Name: v0.Name,
PausedAt: v0.PausedAt,
Rules: convertRulesV0ToCurrent(v0.Rules),
SourceID: v0.SourceID,
TeamID: v0.TeamID,
UpdatedAt: v0.UpdatedAt,
}
}

// convertRulesV0ToCurrent converts V0 rules to the current rule format.
func convertRulesV0ToCurrent(rulesV0 []ruleV0) []rule {
if rulesV0 == nil {
return nil
}

result := make([]rule, 0, len(rulesV0))
for _, r := range rulesV0 {
newRule := rule{}

if r.DelayRule != nil {
newRule.DelayRule = r.DelayRule
}
if r.FilterRule != nil {
newRule.FilterRule = convertFilterRuleV0ToCurrent(r.FilterRule)
}
if r.RetryRule != nil {
newRule.RetryRule = &retryRule{
Count: r.RetryRule.Count,
Interval: r.RetryRule.Interval,
Strategy: r.RetryRule.Strategy,
ResponseStatusCodes: types.ListNull(types.StringType), // V0 didn't have this field
}
}
if r.TransformRule != nil {
newRule.TransformRule = r.TransformRule
}
// DeduplicateRule stays nil (didn't exist in V0)

result = append(result, newRule)
}
return result
}

// convertFilterRuleV0ToCurrent converts a V0 filter rule to the current format.
// The main difference is that V0 used plain types.String for JSON, while current uses jsontypes.Normalized.
func convertFilterRuleV0ToCurrent(v0 *filterRuleV0) *filterRule {
if v0 == nil {
return nil
}

return &filterRule{
Body: convertFilterRulePropertyV0ToCurrent(v0.Body),
Headers: convertFilterRulePropertyV0ToCurrent(v0.Headers),
Path: convertFilterRulePropertyV0ToCurrent(v0.Path),
Query: convertFilterRulePropertyV0ToCurrent(v0.Query),
}
}

// convertFilterRulePropertyV0ToCurrent converts a V0 filter rule property to the current format.
func convertFilterRulePropertyV0ToCurrent(v0 *filterRulePropertyV0) *filterRuleProperty {
if v0 == nil {
return nil
}

result := &filterRuleProperty{
Boolean: v0.Boolean,
Number: v0.Number,
String: v0.String,
}

// Convert plain string JSON to jsontypes.Normalized
if !v0.JSON.IsNull() && !v0.JSON.IsUnknown() {
result.JSON = jsontypes.NewNormalizedValue(v0.JSON.ValueString())
} else {
result.JSON = jsontypes.NewNormalizedNull()
}

return result
}
Loading