From 78814db51e0aaafdee2a9aa03e7e5776095ccda6 Mon Sep 17 00:00:00 2001 From: beatz174-bit Date: Fri, 24 Apr 2026 17:46:22 +1000 Subject: [PATCH] Allow optional dns record content and omit when unset --- internal/dynuclient/client.go | 60 ++++++++++--------- internal/dynuclient/client_test.go | 55 +++++++++++++++-- internal/provider/resource_dns_record.go | 56 +++++++++++++++-- internal/provider/resource_dns_record_test.go | 40 ++++++++++++- 4 files changed, 172 insertions(+), 39 deletions(-) diff --git a/internal/dynuclient/client.go b/internal/dynuclient/client.go index 5864134..47509f5 100644 --- a/internal/dynuclient/client.go +++ b/internal/dynuclient/client.go @@ -109,23 +109,23 @@ type DNSRecord struct { } type CreateDNSRecordRequest struct { - NodeName string `json:"nodeName,omitempty"` - RecordType string `json:"recordType"` - Content string `json:"content"` - TTL int64 `json:"ttl,omitempty"` - State *bool `json:"state,omitempty"` - Group string `json:"group,omitempty"` - Host string `json:"host,omitempty"` + NodeName string `json:"nodeName,omitempty"` + RecordType string `json:"recordType"` + Content *string `json:"content,omitempty"` + TTL int64 `json:"ttl,omitempty"` + State *bool `json:"state,omitempty"` + Group string `json:"group,omitempty"` + Host string `json:"host,omitempty"` } type UpdateDNSRecordRequest struct { - NodeName string `json:"nodeName,omitempty"` - RecordType string `json:"recordType"` - Content string `json:"content"` - TTL int64 `json:"ttl,omitempty"` - State *bool `json:"state,omitempty"` - Group string `json:"group,omitempty"` - Host string `json:"host,omitempty"` + NodeName string `json:"nodeName,omitempty"` + RecordType string `json:"recordType"` + Content *string `json:"content,omitempty"` + TTL int64 `json:"ttl,omitempty"` + State *bool `json:"state,omitempty"` + Group string `json:"group,omitempty"` + Host string `json:"host,omitempty"` } type listDomainsResponse struct { @@ -310,18 +310,18 @@ func parseAPIException(payload []byte) error { } type dnsRecordUpsertPayload struct { - NodeName string `json:"nodeName,omitempty"` - RecordType string `json:"recordType"` - Content string `json:"content,omitempty"` - IPv4Address string `json:"ipv4Address,omitempty"` - IPv6Address string `json:"ipv6Address,omitempty"` - TTL int64 `json:"ttl,omitempty"` - State *bool `json:"state,omitempty"` - Group string `json:"group,omitempty"` - Host string `json:"host,omitempty"` + NodeName string `json:"nodeName,omitempty"` + RecordType string `json:"recordType"` + Content *string `json:"content,omitempty"` + IPv4Address string `json:"ipv4Address,omitempty"` + IPv6Address string `json:"ipv6Address,omitempty"` + TTL int64 `json:"ttl,omitempty"` + State *bool `json:"state,omitempty"` + Group string `json:"group,omitempty"` + Host string `json:"host,omitempty"` } -func buildDNSRecordUpsertPayload(recordType string, nodeName string, content string, ttl int64, state *bool, group string, host string) dnsRecordUpsertPayload { +func buildDNSRecordUpsertPayload(recordType string, nodeName string, content *string, ttl int64, state *bool, group string, host string) dnsRecordUpsertPayload { payload := dnsRecordUpsertPayload{ NodeName: nodeName, RecordType: recordType, @@ -334,12 +334,16 @@ func buildDNSRecordUpsertPayload(recordType string, nodeName string, content str switch strings.ToUpper(strings.TrimSpace(recordType)) { case "A": - payload.IPv4Address = content + if content != nil { + payload.IPv4Address = *content + } case "AAAA": - payload.IPv6Address = content + if content != nil { + payload.IPv6Address = *content + } case "CNAME": - if payload.Host == "" { - payload.Host = content + if payload.Host == "" && content != nil { + payload.Host = *content } } diff --git a/internal/dynuclient/client_test.go b/internal/dynuclient/client_test.go index c8d6c41..36f0f05 100644 --- a/internal/dynuclient/client_test.go +++ b/internal/dynuclient/client_test.go @@ -95,7 +95,7 @@ func TestClientDNSRecordCRUD(t *testing.T) { created, err := client.CreateDNSRecord(context.Background(), 1001, dynuclient.CreateDNSRecordRequest{ NodeName: "api", RecordType: "TXT", - Content: "created", + Content: stringPointer("created"), TTL: 120, State: &state, Group: "integration", @@ -118,7 +118,7 @@ func TestClientDNSRecordCRUD(t *testing.T) { updated, err := client.UpdateDNSRecord(context.Background(), 1001, created.ID, dynuclient.UpdateDNSRecordRequest{ NodeName: "api", RecordType: "TXT", - Content: "updated", + Content: stringPointer("updated"), TTL: 180, State: &state, }) @@ -145,7 +145,7 @@ func TestClientDNSRecordWriteAPIError(t *testing.T) { fake.SetAPIError("/dns/1001/record", fakedynu.APIError{HTTPStatus: 400, StatusCode: 400, Type: "Validation Exception", Message: "recordType invalid"}) client := dynuclient.New("test-key", dynuclient.WithBaseURL(fake.BaseURL()), dynuclient.WithHTTPClient(fake.Client())) - _, err := client.CreateDNSRecord(context.Background(), 1001, dynuclient.CreateDNSRecordRequest{RecordType: "", Content: "x"}) + _, err := client.CreateDNSRecord(context.Background(), 1001, dynuclient.CreateDNSRecordRequest{RecordType: "", Content: stringPointer("x")}) if err == nil || !strings.Contains(err.Error(), "Validation Exception") { t.Fatalf("expected validation API error, got %v", err) } @@ -179,7 +179,7 @@ func TestClientCreateDNSRecordSendsIPv4AddressForARecord(t *testing.T) { _, err := client.CreateDNSRecord(context.Background(), 1001, dynuclient.CreateDNSRecordRequest{ NodeName: "www", RecordType: "A", - Content: "167.179.167.166", + Content: stringPointer("167.179.167.166"), TTL: 300, }) if err != nil { @@ -216,7 +216,7 @@ func TestClientCreateDNSRecordNormalizesZoneStyleContent(t *testing.T) { record, err := client.CreateDNSRecord(context.Background(), 1001, dynuclient.CreateDNSRecordRequest{ NodeName: "www", RecordType: "A", - Content: "167.179.167.166", + Content: stringPointer("167.179.167.166"), TTL: 300, }) if err != nil { @@ -239,3 +239,48 @@ func TestClientDoRequestTopLevelAPIExceptionPayload(t *testing.T) { t.Fatalf("expected top-level API error, got %v", err) } } + +func TestClientCreateDNSRecordOmitsContentWhenUnset(t *testing.T) { + var captured map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/dns/1001/record" { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { + t.Fatalf("failed to decode payload: %v", err) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "statusCode": 200, + "id": 10, + "domainId": 1001, + "domainName": "example.com", + "nodeName": "www", + "hostname": "www.example.com", + "recordType": "A", + "ttl": 300, + "state": true, + }) + })) + defer server.Close() + + client := dynuclient.New("test-key", dynuclient.WithBaseURL(server.URL), dynuclient.WithHTTPClient(server.Client())) + _, err := client.CreateDNSRecord(context.Background(), 1001, dynuclient.CreateDNSRecordRequest{ + NodeName: "www", + RecordType: "A", + TTL: 300, + }) + if err != nil { + t.Fatalf("CreateDNSRecord() error = %v", err) + } + + if _, ok := captured["content"]; ok { + t.Fatalf("expected content to be omitted, got %#v", captured["content"]) + } + if _, ok := captured["ipv4Address"]; ok { + t.Fatalf("expected ipv4Address to be omitted, got %#v", captured["ipv4Address"]) + } +} + +func stringPointer(value string) *string { + return &value +} diff --git a/internal/provider/resource_dns_record.go b/internal/provider/resource_dns_record.go index 759b74a..3faa817 100644 --- a/internal/provider/resource_dns_record.go +++ b/internal/provider/resource_dns_record.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -64,7 +65,7 @@ func (r *dnsRecordResource) Schema(_ context.Context, _ resource.SchemaRequest, }, }, "record_type": schema.StringAttribute{Required: true, Description: "DNS record type (A, AAAA, CNAME, TXT, etc.).", Validators: []validator.String{stringvalidator.LengthAtLeast(1)}}, - "content": schema.StringAttribute{Required: true, Description: "Record content/value."}, + "content": schema.StringAttribute{Optional: true, Computed: true, Description: "Record content/value."}, "ttl": schema.Int64Attribute{ Optional: true, Computed: true, @@ -110,16 +111,19 @@ func (r *dnsRecordResource) Create(ctx context.Context, req resource.CreateReque createReq := dynuclient.CreateDNSRecordRequest{ NodeName: recordNodeName(plan.NodeName, plan.Hostname, domainName), RecordType: strings.TrimSpace(plan.RecordType.ValueString()), - Content: strings.TrimSpace(plan.Content.ValueString()), + Content: stringPointerFromOptional(plan.Content), TTL: int64FromOptional(plan.TTL), State: boolPointerFromOptional(plan.State), Group: stringFromOptional(plan.Group), Host: stringFromOptional(plan.Host), } + if !validateDNSRecordContentForType(createReq.RecordType, createReq.Content, &resp.Diagnostics) { + return + } record, err := r.clientProvider.client.CreateDNSRecord(ctx, domainID, createReq) if err != nil { - resp.Diagnostics.AddError(diagnosticSummary("Unable to create Dynu DNS record", err), err.Error()) + addDNSRecordWriteDiagnostic("create", createReq.RecordType, createReq.Content, err, &resp.Diagnostics) return } @@ -183,15 +187,18 @@ func (r *dnsRecordResource) Update(ctx context.Context, req resource.UpdateReque updateReq := dynuclient.UpdateDNSRecordRequest{ NodeName: recordNodeName(plan.NodeName, plan.Hostname, domainName), RecordType: strings.TrimSpace(plan.RecordType.ValueString()), - Content: strings.TrimSpace(plan.Content.ValueString()), + Content: stringPointerFromOptional(plan.Content), TTL: int64FromOptional(plan.TTL), State: boolPointerFromOptional(plan.State), Group: stringFromOptional(plan.Group), Host: stringFromOptional(plan.Host), } + if !validateDNSRecordContentForType(updateReq.RecordType, updateReq.Content, &resp.Diagnostics) { + return + } if _, err := r.clientProvider.client.UpdateDNSRecord(ctx, domainID, recordID, updateReq); err != nil { - resp.Diagnostics.AddError(diagnosticSummary("Unable to update Dynu DNS record", err), err.Error()) + addDNSRecordWriteDiagnostic("update", updateReq.RecordType, updateReq.Content, err, &resp.Diagnostics) return } @@ -314,3 +321,42 @@ func stringFromOptional(value types.String) string { } return strings.TrimSpace(value.ValueString()) } + +func stringPointerFromOptional(value types.String) *string { + if value.IsNull() || value.IsUnknown() { + return nil + } + trimmed := strings.TrimSpace(value.ValueString()) + return &trimmed +} + +func validateDNSRecordContentForType(recordType string, content *string, diagnostics *diag.Diagnostics) bool { + normalizedType := strings.ToUpper(strings.TrimSpace(recordType)) + if normalizedType == "A" || normalizedType == "AAAA" { + return true + } + + if content == nil || strings.TrimSpace(*content) == "" { + diagnostics.AddError( + "Missing required content for DNS record type", + fmt.Sprintf("The %q record type requires a non-empty content value. Set the content attribute or choose a type that supports omitted content (A/AAAA).", normalizedType), + ) + return false + } + + return true +} + +func addDNSRecordWriteDiagnostic(operation string, recordType string, content *string, err error, diagnostics *diag.Diagnostics) { + detail := err.Error() + var apiErr *dynuclient.APIError + if errors.As(err, &apiErr) { + presence := "omitted" + if content != nil { + presence = fmt.Sprintf("set to %q", *content) + } + detail = fmt.Sprintf("%s. Dynu rejected this %s request for record type %q where content was %s.", err.Error(), operation, strings.ToUpper(strings.TrimSpace(recordType)), presence) + } + + diagnostics.AddError(diagnosticSummary(fmt.Sprintf("Unable to %s Dynu DNS record", operation), err), detail) +} diff --git a/internal/provider/resource_dns_record_test.go b/internal/provider/resource_dns_record_test.go index 9aa2f64..f9a5247 100644 --- a/internal/provider/resource_dns_record_test.go +++ b/internal/provider/resource_dns_record_test.go @@ -1,6 +1,10 @@ package provider -import "testing" +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-framework/diag" +) func TestParseDNSRecordID(t *testing.T) { domainID, recordID, err := parseDNSRecordID("1001/55") @@ -17,3 +21,37 @@ func TestParseDNSRecordIDInvalid(t *testing.T) { t.Fatal("expected parse error") } } + +func TestValidateDNSRecordContentForType(t *testing.T) { + nonEmpty := "hello" + blank := "" + + tests := []struct { + name string + recordType string + content *string + wantValid bool + }{ + {name: "A allows nil content", recordType: "A", content: nil, wantValid: true}, + {name: "AAAA allows nil content", recordType: "AAAA", content: nil, wantValid: true}, + {name: "TXT requires content", recordType: "TXT", content: nil, wantValid: false}, + {name: "TXT rejects blank content", recordType: "TXT", content: &blank, wantValid: false}, + {name: "TXT accepts non-empty content", recordType: "TXT", content: &nonEmpty, wantValid: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + diags := diag.Diagnostics{} + got := validateDNSRecordContentForType(tc.recordType, tc.content, &diags) + if got != tc.wantValid { + t.Fatalf("validateDNSRecordContentForType()=%v, want %v", got, tc.wantValid) + } + if tc.wantValid && diags.HasError() { + t.Fatalf("expected no error diagnostics, got %#v", diags) + } + if !tc.wantValid && !diags.HasError() { + t.Fatal("expected error diagnostics") + } + }) + } +}