diff --git a/internal/provider/attribute_plan_modifier/requires_replace_empty_null.go b/internal/provider/attribute_plan_modifier/requires_replace_empty_null.go new file mode 100644 index 00000000..288d6f37 --- /dev/null +++ b/internal/provider/attribute_plan_modifier/requires_replace_empty_null.go @@ -0,0 +1,77 @@ +package attribute_plan_modifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func RequiresReplaceNullEmpty() tfsdk.AttributePlanModifier { + return requiresReplaceNullEmpty{} +} + +type requiresReplaceNullEmpty struct{} + +func (r requiresReplaceNullEmpty) Modify(ctx context.Context, req tfsdk.ModifyAttributePlanRequest, resp *tfsdk.ModifyAttributePlanResponse) { + if req.AttributeConfig == nil || req.AttributePlan == nil || req.AttributeState == nil { + // shouldn't happen, but let's not panic if it does + return + } + + if req.AttributePlan.IsUnknown() { + + emptyStateString := types.String{} + + if req.AttributeState.Equal(emptyStateString) { + resp.AttributePlan = emptyStateString + return + } + + nullStateString := types.String{ + Null: true, + } + + if req.AttributeState.Equal(nullStateString) { + resp.AttributePlan = nullStateString + return + } + + emptyStateList := types.List{ + ElemType: types.StringType, + Elems: []attr.Value{}, + } + + if req.AttributeState.Equal(emptyStateList) { + resp.AttributePlan = emptyStateList + return + } + + nullStateList := types.List{ + Null: true, + ElemType: types.StringType, + } + + if req.AttributeState.Equal(nullStateList) { + resp.AttributePlan = nullStateList + return + } + } + + if req.AttributePlan.Equal(req.AttributeState) { + // if the plan and the state are in agreement, this attribute + // isn't changing, don't require replace + return + } + + resp.RequiresReplace = true +} + +func (r requiresReplaceNullEmpty) Description(ctx context.Context) string { + return "If the value of this attribute changes, Terraform will destroy and recreate the resource." +} + +func (r requiresReplaceNullEmpty) MarkdownDescription(ctx context.Context) string { + return "If the value of this attribute changes, Terraform will destroy and recreate the resource." +} diff --git a/internal/provider/resource_self_signed_cert.go b/internal/provider/resource_self_signed_cert.go index 7c975c0c..a4e871fb 100644 --- a/internal/provider/resource_self_signed_cert.go +++ b/internal/provider/resource_self_signed_cert.go @@ -16,6 +16,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-provider-tls/internal/provider/attribute_plan_modifier" ) @@ -235,24 +236,27 @@ func (r *selfSignedCertResource) GetSchema(_ context.Context) (tfsdk.Schema, dia "organization": { Type: types.StringType, Optional: true, + Computed: true, PlanModifiers: []tfsdk.AttributePlanModifier{ - resource.RequiresReplace(), + attribute_plan_modifier.RequiresReplaceNullEmpty(), }, Description: "Distinguished name: `O`", }, "common_name": { Type: types.StringType, Optional: true, + Computed: true, PlanModifiers: []tfsdk.AttributePlanModifier{ - resource.RequiresReplace(), + attribute_plan_modifier.RequiresReplaceNullEmpty(), }, Description: "Distinguished name: `CN`", }, "organizational_unit": { Type: types.StringType, Optional: true, + Computed: true, PlanModifiers: []tfsdk.AttributePlanModifier{ - resource.RequiresReplace(), + attribute_plan_modifier.RequiresReplaceNullEmpty(), }, Description: "Distinguished name: `OU`", }, @@ -261,48 +265,54 @@ func (r *selfSignedCertResource) GetSchema(_ context.Context) (tfsdk.Schema, dia ElemType: types.StringType, }, Optional: true, + Computed: true, PlanModifiers: []tfsdk.AttributePlanModifier{ - resource.RequiresReplace(), + attribute_plan_modifier.RequiresReplaceNullEmpty(), }, Description: "Distinguished name: `STREET`", }, "locality": { Type: types.StringType, Optional: true, + Computed: true, PlanModifiers: []tfsdk.AttributePlanModifier{ - resource.RequiresReplace(), + attribute_plan_modifier.RequiresReplaceNullEmpty(), }, Description: "Distinguished name: `L`", }, "province": { Type: types.StringType, Optional: true, + Computed: true, PlanModifiers: []tfsdk.AttributePlanModifier{ - resource.RequiresReplace(), + attribute_plan_modifier.RequiresReplaceNullEmpty(), }, Description: "Distinguished name: `ST`", }, "country": { Type: types.StringType, Optional: true, + Computed: true, PlanModifiers: []tfsdk.AttributePlanModifier{ - resource.RequiresReplace(), + attribute_plan_modifier.RequiresReplaceNullEmpty(), }, Description: "Distinguished name: `C`", }, "postal_code": { Type: types.StringType, Optional: true, + Computed: true, PlanModifiers: []tfsdk.AttributePlanModifier{ - resource.RequiresReplace(), + attribute_plan_modifier.RequiresReplaceNullEmpty(), }, Description: "Distinguished name: `PC`", }, "serial_number": { Type: types.StringType, Optional: true, + Computed: true, PlanModifiers: []tfsdk.AttributePlanModifier{ - resource.RequiresReplace(), + attribute_plan_modifier.RequiresReplaceNullEmpty(), }, Description: "Distinguished name: `SERIALNUMBER`", }, diff --git a/internal/provider/resource_self_signed_cert_test.go b/internal/provider/resource_self_signed_cert_test.go index 721307bd..035f1366 100644 --- a/internal/provider/resource_self_signed_cert_test.go +++ b/internal/provider/resource_self_signed_cert_test.go @@ -11,6 +11,7 @@ import ( "time" r "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" "github.com/hashicorp/terraform-provider-tls/internal/provider/fixtures" tu "github.com/hashicorp/terraform-provider-tls/internal/provider/testutils" @@ -192,6 +193,51 @@ func TestAccResourceSelfSignedCert_UpgradeFromVersion3_4_0(t *testing.T) { }) } +func TestAccResourceSelfSignedCert_UpgradeFromVersion3_4_0_Preserve_Empty_Strings(t *testing.T) { + var id1, id2 string + + config := `resource "tls_private_key" "example" { + algorithm = "ECDSA" +} + +resource "tls_self_signed_cert" "example" { + private_key_pem = tls_private_key.example.private_key_pem + is_ca_certificate = true + + subject { + organization = "Example" + } + + # 3 Years + validity_period_hours = 24 * 365 * 3 + + allowed_uses = [ + "cert_signing", + "crl_signing", + ] +}` + + r.Test(t, r.TestCase{ + Steps: []r.TestStep{ + { + ExternalProviders: providerVersion340(), + Config: config, + Check: r.ComposeTestCheckFunc( + testExtractResourceAttr("tls_self_signed_cert.example", "id", &id1), + ), + }, + { + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Config: config, + Check: r.ComposeTestCheckFunc( + testExtractResourceAttr("tls_self_signed_cert.example", "id", &id2), + testCheckAttributeValuesEqual(&id1, &id2), + ), + }, + }, + }) +} + func TestResourceSelfSignedCert_DetectExpiringAndExpired(t *testing.T) { oldNow := overridableTimeFunc r.UnitTest(t, r.TestCase{ @@ -753,3 +799,13 @@ func TestResourceSelfSignedCert_NoSubject(t *testing.T) { }, }) } + +func testCheckAttributeValuesEqual(i *string, j *string) r.TestCheckFunc { + return func(s *terraform.State) error { + if testStringValue(i) != testStringValue(j) { + return fmt.Errorf("attribute values are different, got %s and %s", testStringValue(i), testStringValue(j)) + } + + return nil + } +}