Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased](https://github.com/fivetran/terraform-provider-fivetran/compare/v1.9.14...HEAD)

### Fixed
- Fixed "Provider produced inconsistent result after apply" error for `fivetran_destination` resource when using PrivateLink with Databricks destinations. When PrivateLink is configured, Fivetran's API returns modified values for `server_host_name` (PrivateLink endpoint), `cloud_provider`, `networking_method`, and `private_link_id`. The provider now preserves the user's original configuration values for these fields to maintain Terraform state consistency.
- Fixed "Provider produced inconsistent result after apply" error for `fivetran_destination` resource when changing `run_setup_tests` from `false` to `true`. The provider now correctly preserves networking-related fields (`private_link_id`, `networking_method`, `hybrid_deployment_agent_id`) when running setup tests without config changes.

## [v1.9.14](https://github.com/fivetran/terraform-provider-fivetran/compare/v1.9.13...v1.9.14)

### Added
Expand Down
113 changes: 113 additions & 0 deletions TESTING_PRIVATELINK_FIX.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Testing the PrivateLink Fix

This document describes how to test the fix for the PrivateLink state inconsistency issue with Databricks destinations.

## Bug Description

When using PrivateLink with Databricks destinations, Fivetran's API returns modified values:
- `server_host_name`: Changed from the original Databricks hostname to the PrivateLink endpoint
- `cloud_provider`: Changed from "AZURE" to "AWS" (incorrect)

This caused Terraform to report: "Provider produced inconsistent result after apply"

## Fix Implementation

The fix preserves the user's original configuration values for `server_host_name` and `cloud_provider` when:
1. The destination `service` is "databricks"
2. The `networking_method` is "PrivateLink"

## Testing Locally

### Prerequisites
- Go 1.19 or later
- Access to a Fivetran account
- Azure Databricks instance with PrivateLink configured
- Fivetran PrivateLink setup

### Build the Provider

```bash
cd terraform-provider-fivetran
make build
```

### Test Configuration

Create a test Terraform configuration:

```hcl
terraform {
required_providers {
fivetran = {
source = "registry.terraform.io/fivetran/fivetran"
version = "~> 1.9"
}
}
}

provider "fivetran" {
# Configure your Fivetran API credentials
}

resource "fivetran_group" "test_group" {
name = "test_privatelink_group"
}

resource "fivetran_destination" "test_destination" {
group_id = fivetran_group.test_group.id
service = "databricks"
time_zone_offset = "0"
region = "AZURE_EASTUS"
trust_certificates = true
trust_fingerprints = true
daylight_saving_time_enabled = true
run_setup_tests = false
networking_method = "PrivateLink"
private_link_id = "<your_private_link_id>"

config {
auth_type = "PERSONAL_ACCESS_TOKEN"
catalog = "<your_catalog>"
server_host_name = "<your_azure_databricks_hostname>"
port = 443
http_path = "<your_http_path>"
cloud_provider = "AZURE"
personal_access_token = "<your_token>"
}
}
```

### Test Steps

1. Run `terraform plan` - should show resource creation
2. Run `terraform apply` - should succeed WITHOUT the "Provider produced inconsistent result" error
3. Run `terraform plan` again - should show "No changes" (not showing drift for `server_host_name` or `cloud_provider`)
4. Modify another field (e.g., `time_zone_offset`)
5. Run `terraform apply` - should succeed and preserve the original values

### Expected Behavior

**Before the fix:**
- `terraform apply` fails with: "Provider produced inconsistent result after apply"
- User needs to add `lifecycle { ignore_changes = [config.server_host_name, config.cloud_provider] }`

**After the fix:**
- `terraform apply` succeeds
- State file contains the user's original `server_host_name` and `cloud_provider` values
- No need for `lifecycle.ignore_changes` workaround

## Alternative Testing

If you don't have a full PrivateLink setup, you can:

1. Review the code changes in `fivetran/framework/resources/destination.go`
2. Check that the `preservePrivateLinkPlanValues()` function:
- Only activates when `networking_method == "PrivateLink"` and `service == "databricks"`
- Correctly preserves plan values for `server_host_name` and `cloud_provider`
- Doesn't affect other destination types or networking methods

## Files Changed

- `fivetran/framework/resources/destination.go`: Added fix logic
- `CHANGELOG.md`: Documented the fix

104 changes: 92 additions & 12 deletions fivetran/framework/resources/destination.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/fivetran/terraform-provider-fivetran/fivetran/framework/core"
"github.com/fivetran/terraform-provider-fivetran/fivetran/framework/core/model"
fivetranSchema "github.com/fivetran/terraform-provider-fivetran/fivetran/framework/core/schema"
"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"
Expand Down Expand Up @@ -78,6 +79,9 @@ func (r *destination) Create(ctx context.Context, req resource.CreateRequest, re
return
}

// Save plan config for PrivateLink scenarios before API modifies it
planConfig := data.Config

configMap, err := data.GetConfigMap(true)
if err != nil {
resp.Diagnostics.AddError(
Expand Down Expand Up @@ -180,6 +184,12 @@ func (r *destination) Create(ctx context.Context, req resource.CreateRequest, re
} else {
data.ReadFromResponseWithTests(response)
}

// Preserve plan values for PrivateLink scenarios
// When using PrivateLink with Databricks, Fivetran's API may modify server_host_name and cloud_provider
// We need to preserve the user's original values to avoid Terraform state inconsistencies
preservePrivateLinkValues(ctx, &data, &data, planConfig, resp)

data.RunSetupTests = types.BoolValue(runSetupTestsPlan)
data.TrustCertificates = types.BoolValue(trustCertificatesPlan)
data.TrustFingerprints = types.BoolValue(trustFingerprintsPlan)
Expand Down Expand Up @@ -243,6 +253,9 @@ func (r *destination) Update(ctx context.Context, req resource.UpdateRequest, re
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)

// Save plan config for PrivateLink scenarios before API modifies it
planConfig := plan.Config

runSetupTestsPlan := core.GetBoolOrDefault(plan.RunSetupTests, true)
trustCertificatesPlan := core.GetBoolOrDefault(plan.TrustCertificates, false)
trustFingerprintsPlan := core.GetBoolOrDefault(plan.TrustFingerprints, false)
Expand Down Expand Up @@ -332,21 +345,25 @@ func (r *destination) Update(ctx context.Context, req resource.UpdateRequest, re
return
}

plan.ReadFromLegacyResponse(response)
if response.Data.SetupTests != nil && len(response.Data.SetupTests) > 0 {
for _, tr := range response.Data.SetupTests {
if tr.Status != "PASSED" && tr.Status != "SKIPPED" {
resp.Diagnostics.AddWarning(
fmt.Sprintf("Destination setup test `%v` has status `%v`", tr.Title, tr.Status),
tr.Message,
)
}
plan.ReadFromLegacyResponse(response)
if response.Data.SetupTests != nil && len(response.Data.SetupTests) > 0 {
for _, tr := range response.Data.SetupTests {
if tr.Status != "PASSED" && tr.Status != "SKIPPED" {
resp.Diagnostics.AddWarning(
fmt.Sprintf("Destination setup test `%v` has status `%v`", tr.Title, tr.Status),
tr.Message,
)
}
}
}

// there were no changes in config so we can just copy it from state
plan.Config = state.Config
updatePerformed = true
// there were no changes in config so we can just copy it from state
plan.Config = state.Config
// Also preserve networking-related fields that aren't in the legacy response
plan.PrivateLinkId = state.PrivateLinkId
plan.NetworkingMethod = state.NetworkingMethod
plan.HybridDeploymentAgentId = state.HybridDeploymentAgentId
updatePerformed = true
}
}

Expand All @@ -363,6 +380,11 @@ func (r *destination) Update(ctx context.Context, req resource.UpdateRequest, re
plan.ReadFromResponse(response)
}

// Preserve plan values for PrivateLink scenarios
// When using PrivateLink with Databricks, Fivetran's API may modify server_host_name and cloud_provider
// We need to preserve the user's original values to avoid Terraform state inconsistencies
preservePrivateLinkValues(ctx, &plan, &state, planConfig, resp)

// Set up synthetic values
if plan.RunSetupTests.IsUnknown() {
plan.RunSetupTests = state.RunSetupTests
Expand Down Expand Up @@ -400,3 +422,61 @@ func (r *destination) Delete(ctx context.Context, req resource.DeleteRequest, re
return
}
}

// preservePrivateLinkValues preserves certain plan values when using PrivateLink with Databricks.
// When PrivateLink is configured, Fivetran's API may return modified values for server_host_name, cloud_provider,
// networking_method, and private_link_id, which can cause Terraform to report "Provider produced inconsistent result
// after apply" errors. This function restores the user's original plan values to maintain consistency.
func preservePrivateLinkValues(ctx context.Context, result *model.DestinationResourceModel, plan *model.DestinationResourceModel, planConfig types.Object, resp interface{}) {
// Only process if plan specifies PrivateLink with Databricks (check plan, not result, since result may be modified)
if plan.NetworkingMethod.ValueString() != "PrivateLink" || plan.Service.ValueString() != "databricks" {
return
}

// Preserve top-level networking fields
result.NetworkingMethod = plan.NetworkingMethod
result.PrivateLinkId = plan.PrivateLinkId

// Get plan config attributes
planConfigAttrs := planConfig.Attributes()
if planConfigAttrs == nil {
return
}

// Get result config attributes - need to make a copy since map is reference type
resultConfigAttrs := result.Config.Attributes()
if resultConfigAttrs == nil {
return
}

// Create a new map with all current result attributes
newConfigAttrs := make(map[string]attr.Value)
for k, v := range resultConfigAttrs {
newConfigAttrs[k] = v
}

// Preserve server_host_name from plan if present
if serverHostName, ok := planConfigAttrs["server_host_name"]; ok && !serverHostName.IsNull() && !serverHostName.IsUnknown() {
newConfigAttrs["server_host_name"] = serverHostName
}

// Preserve cloud_provider from plan if present
if cloudProvider, ok := planConfigAttrs["cloud_provider"]; ok && !cloudProvider.IsNull() && !cloudProvider.IsUnknown() {
newConfigAttrs["cloud_provider"] = cloudProvider
}

// Reconstruct the config object with preserved values
preservedConfig, diags := types.ObjectValue(result.Config.AttributeTypes(ctx), newConfigAttrs)
if resp != nil {
switch r := resp.(type) {
case *resource.CreateResponse:
r.Diagnostics.Append(diags...)
case *resource.UpdateResponse:
r.Diagnostics.Append(diags...)
}
}

if !diags.HasError() {
result.Config = preservedConfig
}
}
Loading