diff --git a/stackit/internal/services/dns/dns_acc_test.go b/stackit/internal/services/dns/dns_acc_test.go index 201ab118a..607737f55 100644 --- a/stackit/internal/services/dns/dns_acc_test.go +++ b/stackit/internal/services/dns/dns_acc_test.go @@ -83,6 +83,8 @@ func configVarsMaxUpdated() config.Variables { } func TestAccDnsMinResource(t *testing.T) { + dotDnsName := "tf-acc-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha) + ".example.home." + resource.ParallelTest(t, resource.TestCase{ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, CheckDestroy: testAccCheckDnsDestroy, @@ -247,6 +249,20 @@ func TestAccDnsMinResource(t *testing.T) { resource.TestCheckResourceAttrSet("stackit_dns_record_set.record_set", "fqdn"), resource.TestCheckResourceAttrSet("stackit_dns_record_set.record_set", "state")), }, + // Test trailing dot preservation + { + Config: resourceMinConfig, + ConfigVariables: func() config.Variables { + vars := maps.Clone(testConfigVarsMin) + vars["dns_name"] = config.StringVariable(dotDnsName) + return vars + }(), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_dns_zone.zone", "project_id", testutil.ProjectId), + // Verify that the state has the trailing dot, matching the config + resource.TestCheckResourceAttr("stackit_dns_zone.zone", "dns_name", dotDnsName), + ), + }, // Deletion is done by the framework implicitly }, }) diff --git a/stackit/internal/services/dns/zone/resource.go b/stackit/internal/services/dns/zone/resource.go index b7c76e11f..c43f86cf3 100644 --- a/stackit/internal/services/dns/zone/resource.go +++ b/stackit/internal/services/dns/zone/resource.go @@ -537,7 +537,7 @@ func mapFields(ctx context.Context, zoneResp *dns.ZoneResponse, model *Model) er model.Active = types.BoolPointerValue(z.Active) model.ContactEmail = types.StringPointerValue(z.ContactEmail) model.DefaultTTL = types.Int64PointerValue(z.DefaultTTL) - model.DnsName = types.StringPointerValue(z.DnsName) + model.DnsName = reconcileDnsName(model.DnsName, z.DnsName) model.ExpireTime = types.Int64PointerValue(z.ExpireTime) model.IsReverseZone = types.BoolPointerValue(z.IsReverseZone) model.Name = types.StringPointerValue(z.Name) @@ -566,9 +566,16 @@ func toCreatePayload(model *Model) (*dns.CreateZonePayload, error) { } modelPrimaries = append(modelPrimaries, primaryString.ValueString()) } + + dnsName := conversion.StringValueToPointer(model.DnsName) + if dnsName != nil && strings.HasSuffix(*dnsName, ".") { + trimmed := strings.TrimSuffix(*dnsName, ".") + dnsName = &trimmed + } + return &dns.CreateZonePayload{ Name: conversion.StringValueToPointer(model.Name), - DnsName: conversion.StringValueToPointer(model.DnsName), + DnsName: dnsName, ContactEmail: conversion.StringValueToPointer(model.ContactEmail), Description: conversion.StringValueToPointer(model.Description), Acl: conversion.StringValueToPointer(model.Acl), @@ -601,3 +608,27 @@ func toUpdatePayload(model *Model) (*dns.PartialUpdateZonePayload, error) { Primaries: nil, // API returns error if this field is set, even if nothing changes }, nil } + +func reconcileDnsName(stateDnsName types.String, apiDnsName *string) types.String { + if apiDnsName == nil { + return types.StringNull() + } + + apiValue := *apiDnsName + + if stateDnsName.IsNull() || stateDnsName.IsUnknown() { + return types.StringValue(apiValue) + } + + stateValue := stateDnsName.ValueString() + + // If state has trailing dot, but API doesn't + if strings.HasSuffix(stateValue, ".") && !strings.HasSuffix(apiValue, ".") { + trimmedState := strings.TrimSuffix(stateValue, ".") + if trimmedState == apiValue { + return stateDnsName // Keep the state value with the dot + } + } + + return types.StringValue(apiValue) +} diff --git a/stackit/internal/services/dns/zone/resource_test.go b/stackit/internal/services/dns/zone/resource_test.go index d12cd90de..5d059f1ba 100644 --- a/stackit/internal/services/dns/zone/resource_test.go +++ b/stackit/internal/services/dns/zone/resource_test.go @@ -185,6 +185,40 @@ func TestMapFields(t *testing.T) { }, true, }, + { + "preserve_trailing_dot", + Model{ + ProjectId: types.StringValue("pid"), + ZoneId: types.StringValue("zid"), + DnsName: types.StringValue("example.com."), + }, + &dns.ZoneResponse{ + Zone: &dns.Zone{ + Id: utils.Ptr("zid"), + DnsName: utils.Ptr("example.com"), + }, + }, + Model{ + Id: types.StringValue("pid,zid"), + ProjectId: types.StringValue("pid"), + ZoneId: types.StringValue("zid"), + Name: types.StringNull(), + DnsName: types.StringValue("example.com."), + Acl: types.StringNull(), + DefaultTTL: types.Int64Null(), + ExpireTime: types.Int64Null(), + RefreshTime: types.Int64Null(), + RetryTime: types.Int64Null(), + SerialNumber: types.Int64Null(), + NegativeCache: types.Int64Null(), + Type: types.StringValue(""), + State: types.StringValue(""), + PrimaryNameServer: types.StringNull(), + Primaries: types.ListNull(types.StringType), + Visibility: types.StringValue(""), + }, + true, + }, { "nullable_fields_and_int_conversions_ok", Model{ @@ -345,6 +379,19 @@ func TestToCreatePayload(t *testing.T) { nil, false, }, + { + "with_trailing_dot", + &Model{ + Name: types.StringValue("Name"), + DnsName: types.StringValue("example.com."), + }, + &dns.CreateZonePayload{ + Name: utils.Ptr("Name"), + DnsName: utils.Ptr("example.com"), + Primaries: &[]string{}, + }, + true, + }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) {