Repository: cloudflare/terraform-provider-cloudflare (Go)

Issue:
cloudflare_ruleset produced inconsistent result for preserve_query_string
### Confirmation

- [X] My issue isn't already found on the issue tracker.
- [X] I have replicated my issue using the latest version of the provider and it is still present.

### Terraform and Cloudflare provider version

Terraform v1.3.5
on darwin_arm64
+ provider registry.terraform.io/cloudflare/cloudflare v4.5.0


### Affected resource(s)

cloudflare_ruleset

### Terraform configuration files

```hcl
resource "cloudflare_ruleset" "stylebop_com_redirect_rules" {
  kind    = "zone"
  name    = "default"
  phase   = "http_request_dynamic_redirect"
  zone_id = data.cloudflare_zone.stylebop_com.id
  rules {
    action = "redirect"
    action_parameters {
      from_value {
        status_code = 301
        target_url {
          expression = "concat(\"https://www.peek-cloppenburg.de\", http.request.uri.path)"
        }
      }
    }
    description = "Redirect Invoice URL to www.peek-cloppenburg.de"
    enabled     = true
    expression  = "(ends_with(http.host, \"stylebop.com\") and starts_with(http.request.uri.path, \"/s3media\"))"
  }

  rules {
    action = "redirect"
    action_parameters {
      from_value {
        status_code = 301
        target_url {
          expression = "concat(\"https://www.peek-cloppenburg.de/de/stylebop\", http.request.uri.path)"
        }
      }
    }
    description = "Redirect to www.peek-cloppenburg.de"
    enabled     = true
    expression  = "(ends_with(http.host, \"stylebop.com\"))"
  }
}
```


### Link to debug output

https://gist.github.com/florianmutter/86feee086b4319fecce056e6e26aa341

### Panic output

_No response_

### Expected output

No changes for resource.

### Actual output

Error: Provider produced inconsistent result after apply

When applying changes to cloudflare_ruleset.stylebop_com_redirect_rules, provider "provider[\"registry.terraform.io/cloudflare/cloudflare\"]" produced an unexpected new value: .rules[0].action_parameters[0].from_value[0].preserve_query_string: was null, but now cty.False.


### Steps to reproduce

1. Create redirect rule in cloudflare dashboard
2. Create configuration in terraform
3. Import existing redirect rule into terraform state
4. Run `terraform apply`

### Additional factoids

_No response_

### References

_No response_


Discussion:
# Community Note

**Voting for Prioritization**

* Please vote on this issue by adding a 👍 [reaction](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) to the original post to help the community and maintainers prioritize this request.
* Please **do not** leave "+1" or other comments that do not add relevant new information or questions, they generate extra noise for issue followers and do not help prioritize the request.

**Volunteering to Work on This Issue**

* If you are interested in working on this issue, please leave a comment.
* If this would be your first contribution, please review the [contribution guide](https://github.com/cloudflare/terraform-provider-cloudflare/tree/master/contributing).
thanks for the issue and reproduction case here. this is partially caused by the underlying Go library so i've addressed that in cloudflare/cloudflare-go#1275 and will push up the provider fix once i resolve some local development issues i'm having.
@jacobbednarz I am getting a very similar error but in my case the error starts when I add a "skip" rule to my cloudflare. I see your cloudflare-go change is merged. Should I just ignore this message until the next cloudflare-go release (which based on previous cadence should be in the next couple of weeks)? The skip rule is created - it just appears to be a problem recording the terraform state thereof. 
@cnk if you're hitting this specific issue (same field names, error messages, etc) you can follow along here and it will note once it is released and ready to be updated to. otherwise, if you're getting a different issue, i'd recommend opening an issue specific to your setup/output to have it triaged as it may be something different and i can only speculate at this point.
This functionality has been released in [v4.6.0 of the Terraform Cloudflare Provider](https://github.com/cloudflare/terraform-provider-cloudflare/releases/tag/v4.6.0).

Please see the [Terraform documentation on provider versioning](https://www.terraform.io/docs/configuration/providers.html#provider-versions) or reach out if you need any assistance upgrading.

For further feature requests or bug reports with this functionality, please create a [new GitHub issue](https://github.com/cloudflare/terraform-provider-cloudflare/issues/new/choose) following the template. Thank you!



Patch:
diff --git a/.changelog/2414.txt b/.changelog/2414.txt
new file mode 100644
index 0000000000..f7c8da5ec2
--- /dev/null
+++ b/.changelog/2414.txt
@@ -0,0 +1,3 @@
+```release-note:bug
+resource/cloudflare_ruleset: allow `FromValue.PreserveQueryString` to be nullable and handled correctly
+```
diff --git a/internal/framework/service/rulesets/resource.go b/internal/framework/service/rulesets/resource.go
index fe85f35d6a..7b55179fd0 100644
--- a/internal/framework/service/rulesets/resource.go
+++ b/internal/framework/service/rulesets/resource.go
@@ -665,13 +665,18 @@ func toRulesetResourceModel(ctx context.Context, zoneID, accountID basetypes.Str
 
 			if ruleResponse.ActionParameters.FromValue != nil {
 				rule.ActionParameters[0].FromValue = []*ActionParameterFromValueModel{{
-					StatusCode:          flatteners.Int64(int64(ruleResponse.ActionParameters.FromValue.StatusCode)),
-					PreserveQueryString: flatteners.Bool(&ruleResponse.ActionParameters.FromValue.PreserveQueryString),
+					StatusCode: flatteners.Int64(int64(ruleResponse.ActionParameters.FromValue.StatusCode)),
 					TargetURL: []*ActionParameterFromValueTargetURLModel{{
 						Value:      flatteners.String(ruleResponse.ActionParameters.FromValue.TargetURL.Value),
 						Expression: flatteners.String(ruleResponse.ActionParameters.FromValue.TargetURL.Expression),
 					}},
 				}}
+
+				if !reflect.ValueOf(ruleResponse.ActionParameters.FromValue.PreserveQueryString).IsNil() {
+					rule.ActionParameters[0].FromValue[0].PreserveQueryString = flatteners.Bool(ruleResponse.ActionParameters.FromValue.PreserveQueryString)
+				} else {
+					rule.ActionParameters[0].FromValue[0].PreserveQueryString = types.BoolNull()
+				}
 			}
 
 			if len(ruleResponse.ActionParameters.Algorithms) > 0 {
@@ -1230,7 +1235,7 @@ func (r *RulesModel) toRulesetRule(ctx context.Context) cloudflare.RulesetRule {
 			}
 
 			if !ap.FromValue[0].PreserveQueryString.IsNull() {
-				from.PreserveQueryString = ap.FromValue[0].PreserveQueryString.ValueBool()
+				from.PreserveQueryString = cloudflare.BoolPtr(ap.FromValue[0].PreserveQueryString.ValueBool())
 			}
 
 			from.TargetURL.Expression = ap.FromValue[0].TargetURL[0].Expression.ValueString()


Test patch:
diff --git a/internal/framework/service/rulesets/resource_test.go b/internal/framework/service/rulesets/resource_test.go
index bff4e0e1b5..c77fc344eb 100644
--- a/internal/framework/service/rulesets/resource_test.go
+++ b/internal/framework/service/rulesets/resource_test.go
@@ -1986,6 +1986,35 @@ func TestAccCloudflareRuleset_DynamicRedirect(t *testing.T) {
 	})
 }
 
+func TestAccCloudflareRuleset_DynamicRedirectWithoutPreservingQueryString(t *testing.T) {
+	rnd := utils.GenerateRandomResourceName()
+	zoneID := os.Getenv("CLOUDFLARE_ZONE_ID")
+	resourceName := "cloudflare_ruleset." + rnd
+
+	resource.ParallelTest(t, resource.TestCase{
+		PreCheck:                 func() { acctest.TestAccPreCheck(t) },
+		ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories,
+		Steps: []resource.TestStep{
+			{
+				Config: testAccCloudflareRulesetRedirectFromValueWithoutPreservingQueryString(rnd, zoneID),
+				Check: resource.ComposeTestCheckFunc(
+					resource.TestCheckResourceAttr(resourceName, "name", rnd),
+					resource.TestCheckResourceAttr(resourceName, "kind", "zone"),
+					resource.TestCheckResourceAttr(resourceName, "phase", "http_request_dynamic_redirect"),
+
+					resource.TestCheckResourceAttr(resourceName, "rules.#", "1"),
+					resource.TestCheckResourceAttr(resourceName, "rules.0.action", "redirect"),
+
+					resource.TestCheckResourceAttr(resourceName, "rules.0.action_parameters.0.from_value.0.status_code", "301"),
+					resource.TestCheckResourceAttr(resourceName, "rules.0.action_parameters.0.from_value.0.target_url.#", "1"),
+					resource.TestCheckNoResourceAttr(resourceName, "rules.0.action_parameters.0.from_value.0.target_url.0.expression"),
+					resource.TestCheckResourceAttr(resourceName, "rules.0.action_parameters.0.from_value.0.target_url.0.value", "some_host.com"),
+				),
+			},
+		},
+	})
+}
+
 func TestAccCloudflareRuleset_TransformationRuleURIStripOffQueryString(t *testing.T) {
 	rnd := utils.GenerateRandomResourceName()
 	zoneID := os.Getenv("CLOUDFLARE_ZONE_ID")
@@ -3752,6 +3781,32 @@ func testAccCloudflareRulesetRedirectFromValue(rnd, zoneID string) string {
   }`, rnd, zoneID)
 }
 
+func testAccCloudflareRulesetRedirectFromValueWithoutPreservingQueryString(rnd, zoneID string) string {
+	return fmt.Sprintf(`
+  resource "cloudflare_ruleset" "%[1]s" {
+	zone_id     = "%[2]s"
+    name        = "%[1]s"
+    description = "%[1]s ruleset description"
+    kind        = "zone"
+    phase       = "http_request_dynamic_redirect"
+
+    rules {
+      action = "redirect"
+      action_parameters {
+        from_value {
+		  status_code = 301
+		  target_url {
+			value = "some_host.com"
+		  }
+        }
+      }
+      expression = "true"
+      description = "Apply redirect from value"
+      enabled = true
+    }
+  }`, rnd, zoneID)
+}
+
 func testAccCheckCloudflareRulesetActionParametersOverrideSensitivityForAllRulesetRules(rnd, name, zoneID, zoneName string) string {
 	return fmt.Sprintf(`
   resource "cloudflare_ruleset" "%[1]s" {