Merge pull request #30 from beatz174-bit/codex/refactor-dynu-blank-a/aaaa-handling

Refactor A/AAAA handling to support Dynu dynamic-IP semantics
This commit is contained in:
beatz174-bit
2026-04-29 09:14:57 +10:00
committed by GitHub
10 changed files with 402 additions and 97 deletions
+16 -1
View File
@@ -157,7 +157,11 @@ Provider resources:
Arguments: Arguments:
- `hostname` (String, required) - `hostname` (String, required)
- `record_type` (String, required) - `record_type` (String, required)
- `content` (String, required) - `content` (String, optional/computed)
- For `A` and `AAAA`, omitted/blank content means Dynu dynamic-IP intent.
- For non-`A`/`AAAA` record types, content is required.
- `dynamic` (Bool, optional/computed)
- Explicit dynamic-mode toggle for `A`/`AAAA`. Existing omitted `content` behavior remains backward compatible.
- `ttl` (Number, optional) - `ttl` (Number, optional)
- `state` (Bool, optional) - `state` (Bool, optional)
- `group` (String, optional) - `group` (String, optional)
@@ -181,8 +185,19 @@ resource "dynu_dns_record" "txt" {
ttl = 300 ttl = 300
state = true state = true
} }
resource "dynu_dns_record" "dynamic_a" {
hostname = "auth.example.com"
record_type = "A"
# content intentionally omitted for Dynu dynamic IPv4 behavior
}
``` ```
Notes:
- Dynu control-panel semantics treat blank `A`/`AAAA` values as dynamic/inherited records, not invalid static records.
- Dynu can surface inherited/current values with parentheses in the UI (for example `(203.0.113.10)`); provider state preserves dynamic intent to avoid perpetual drift from changing live IPs.
- The provider avoids sending empty-string IP payloads, and if Dynu rejects omitted IP fields in an API path, it uses a documented fallback emulation path based on the root domain current address/group metadata.
## Data source schema reference ## Data source schema reference
### `dynu_domains` ### `dynu_domains`
+14 -4
View File
@@ -9,8 +9,8 @@ Using a single suffix (`test_suffix`), this example creates five DNS record scen
1. `A` record with IPv4 content (`codex-a-<suffix>.<root_domain>`) 1. `A` record with IPv4 content (`codex-a-<suffix>.<root_domain>`)
2. `AAAA` record with IPv6 content (`codex-aaaa-<suffix>.<root_domain>`) 2. `AAAA` record with IPv6 content (`codex-aaaa-<suffix>.<root_domain>`)
3. `CNAME` record (`codex-cname-<suffix>.<root_domain>`) 3. `CNAME` record (`codex-cname-<suffix>.<root_domain>`)
4. **Blank `A` record** with no content/IP (`codex-blank-a-<suffix>.<root_domain>`) 4. **Dynamic `A` record** with omitted content (`codex-dynamic-a-<suffix>.<root_domain>`)
5. **Blank `AAAA` record** with no content/IP (`codex-blank-aaaa-<suffix>.<root_domain>`) 5. **Dynamic `AAAA` record** with omitted content (`codex-dynamic-aaaa-<suffix>.<root_domain>`)
> [!WARNING] > [!WARNING]
> Do not use a suffix that overlaps important existing hostnames. This example is intended only for disposable test records that you can safely destroy. > Do not use a suffix that overlaps important existing hostnames. This example is intended only for disposable test records that you can safely destroy.
@@ -52,5 +52,15 @@ terraform destroy
- `dynu_dns_record.a_ipv4` - `dynu_dns_record.a_ipv4`
- `dynu_dns_record.aaaa_ipv6` - `dynu_dns_record.aaaa_ipv6`
- `dynu_dns_record.cname` - `dynu_dns_record.cname`
- `dynu_dns_record.blank_a` - `dynu_dns_record.dynamic_a`
- `dynu_dns_record.blank_aaaa` - `dynu_dns_record.dynamic_aaaa`
Verification commands:
```bash
dig A codex-dynamic-a-<suffix>.<root_domain>
dig AAAA codex-dynamic-aaaa-<suffix>.<root_domain>
dig CNAME codex-cname-<suffix>.<root_domain>
```
`dig <hostname>` defaults to an `A` lookup, which can be misleading for AAAA-only checks.
+8 -8
View File
@@ -9,8 +9,8 @@ locals {
hostname_a_ipv4 = "codex-a-${var.test_suffix}.${var.dynu_root_domain}" hostname_a_ipv4 = "codex-a-${var.test_suffix}.${var.dynu_root_domain}"
hostname_aaaa_ipv6 = "codex-aaaa-${var.test_suffix}.${var.dynu_root_domain}" hostname_aaaa_ipv6 = "codex-aaaa-${var.test_suffix}.${var.dynu_root_domain}"
hostname_cname = "codex-cname-${var.test_suffix}.${var.dynu_root_domain}" hostname_cname = "codex-cname-${var.test_suffix}.${var.dynu_root_domain}"
hostname_blank_a = "codex-blank-a-${var.test_suffix}.${var.dynu_root_domain}" hostname_dynamic_a = "codex-dynamic-a-${var.test_suffix}.${var.dynu_root_domain}"
hostname_blank_aaaa = "codex-blank-aaaa-${var.test_suffix}.${var.dynu_root_domain}" hostname_dynamic_aaaa = "codex-dynamic-aaaa-${var.test_suffix}.${var.dynu_root_domain}"
} }
resource "dynu_dns_record" "a_ipv4" { resource "dynu_dns_record" "a_ipv4" {
@@ -37,17 +37,17 @@ resource "dynu_dns_record" "cname" {
state = true state = true
} }
# Deliberate blank A record scenario: A type with no content/IP value. # Deliberate dynamic A record scenario: content intentionally omitted for Dynu dynamic IPv4 behavior.
resource "dynu_dns_record" "blank_a" { resource "dynu_dns_record" "dynamic_a" {
hostname = local.hostname_blank_a hostname = local.hostname_dynamic_a
record_type = "A" record_type = "A"
ttl = var.test_ttl ttl = var.test_ttl
state = true state = true
} }
# Deliberate blank AAAA record scenario: AAAA type with no content/IP value. # Deliberate dynamic AAAA record scenario: content intentionally omitted for Dynu dynamic IPv6 behavior.
resource "dynu_dns_record" "blank_aaaa" { resource "dynu_dns_record" "dynamic_aaaa" {
hostname = local.hostname_blank_aaaa hostname = local.hostname_dynamic_aaaa
record_type = "AAAA" record_type = "AAAA"
ttl = var.test_ttl ttl = var.test_ttl
state = true state = true
+11 -11
View File
@@ -4,8 +4,8 @@ output "record_hostnames" {
a_ipv4 = dynu_dns_record.a_ipv4.hostname a_ipv4 = dynu_dns_record.a_ipv4.hostname
aaaa_ipv6 = dynu_dns_record.aaaa_ipv6.hostname aaaa_ipv6 = dynu_dns_record.aaaa_ipv6.hostname
cname = dynu_dns_record.cname.hostname cname = dynu_dns_record.cname.hostname
blank_a = dynu_dns_record.blank_a.hostname dynamic_a = dynu_dns_record.dynamic_a.hostname
blank_aaaa = dynu_dns_record.blank_aaaa.hostname dynamic_aaaa = dynu_dns_record.dynamic_aaaa.hostname
} }
} }
@@ -15,13 +15,13 @@ output "record_ids" {
a_ipv4 = dynu_dns_record.a_ipv4.id a_ipv4 = dynu_dns_record.a_ipv4.id
aaaa_ipv6 = dynu_dns_record.aaaa_ipv6.id aaaa_ipv6 = dynu_dns_record.aaaa_ipv6.id
cname = dynu_dns_record.cname.id cname = dynu_dns_record.cname.id
blank_a = dynu_dns_record.blank_a.id dynamic_a = dynu_dns_record.dynamic_a.id
blank_aaaa = dynu_dns_record.blank_aaaa.id dynamic_aaaa = dynu_dns_record.dynamic_aaaa.id
} }
} }
output "record_values" { output "record_values" {
description = "Record type/content summary for each scenario; blank records intentionally omit content." description = "Record type/content summary for each scenario; dynamic A/AAAA intentionally omit content."
value = { value = {
a_ipv4 = { a_ipv4 = {
type = dynu_dns_record.a_ipv4.record_type type = dynu_dns_record.a_ipv4.record_type
@@ -35,13 +35,13 @@ output "record_values" {
type = dynu_dns_record.cname.record_type type = dynu_dns_record.cname.record_type
content = dynu_dns_record.cname.content content = dynu_dns_record.cname.content
} }
blank_a = { dynamic_a = {
type = dynu_dns_record.blank_a.record_type type = dynu_dns_record.dynamic_a.record_type
content = dynu_dns_record.blank_a.content content = dynu_dns_record.dynamic_a.content
} }
blank_aaaa = { dynamic_aaaa = {
type = dynu_dns_record.blank_aaaa.record_type type = dynu_dns_record.dynamic_aaaa.record_type
content = dynu_dns_record.blank_aaaa.content content = dynu_dns_record.dynamic_aaaa.content
} }
} }
} }
+19 -7
View File
@@ -322,10 +322,11 @@ type dnsRecordUpsertPayload struct {
} }
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 {
normalizedContent := normalizeOptionalContent(content)
payload := dnsRecordUpsertPayload{ payload := dnsRecordUpsertPayload{
NodeName: nodeName, NodeName: nodeName,
RecordType: recordType, RecordType: recordType,
Content: content, Content: normalizedContent,
TTL: ttl, TTL: ttl,
State: state, State: state,
Group: group, Group: group,
@@ -334,22 +335,33 @@ func buildDNSRecordUpsertPayload(recordType string, nodeName string, content *st
switch strings.ToUpper(strings.TrimSpace(recordType)) { switch strings.ToUpper(strings.TrimSpace(recordType)) {
case "A": case "A":
if content != nil { if normalizedContent != nil {
payload.IPv4Address = *content payload.IPv4Address = *normalizedContent
} }
case "AAAA": case "AAAA":
if content != nil { if normalizedContent != nil {
payload.IPv6Address = *content payload.IPv6Address = *normalizedContent
} }
case "CNAME": case "CNAME":
if payload.Host == "" && content != nil { if payload.Host == "" && normalizedContent != nil {
payload.Host = *content payload.Host = *normalizedContent
} }
} }
return payload return payload
} }
func normalizeOptionalContent(content *string) *string {
if content == nil {
return nil
}
trimmed := strings.TrimSpace(*content)
if trimmed == "" {
return nil
}
return &trimmed
}
var zoneStyleContentPattern = regexp.MustCompile(`(?i)^\S+\.\s+\d+\s+IN\s+\S+\s+(.+)$`) var zoneStyleContentPattern = regexp.MustCompile(`(?i)^\S+\.\s+\d+\s+IN\s+\S+\s+(.+)$`)
func normalizeDNSRecord(record *DNSRecord) { func normalizeDNSRecord(record *DNSRecord) {
+2 -27
View File
@@ -192,7 +192,7 @@ func testAccCreateRecordMaybeSkipUnsupported(t *testing.T, client *dynuclient.Cl
} }
var apiErr *dynuclient.APIError var apiErr *dynuclient.APIError
if errors.As(err, &apiErr) && isUnsupportedEmptyContentAPIError(apiErr) { if errors.As(err, &apiErr) && isUnsupportedEmptyContentError(apiErr) {
t.Skipf("Dynu account/API does not support %s in this environment (%v)", scenario, err) t.Skipf("Dynu account/API does not support %s in this environment (%v)", scenario, err)
} }
@@ -222,31 +222,6 @@ func testAccDeleteRecord(t *testing.T, client *dynuclient.Client, domainID int64
} }
} }
func isUnsupportedEmptyContentAPIError(apiErr *dynuclient.APIError) bool {
if apiErr == nil || apiErr.StatusCode != 400 {
return false
}
normalizedType := strings.ToLower(strings.TrimSpace(apiErr.Type))
if normalizedType != "validation exception" {
return false
}
normalizedMessage := strings.ToLower(strings.TrimSpace(apiErr.Message))
knownUnsupportedMessages := []string{
"content is required",
"ipv4address is required",
"ipv6address is required",
}
for _, fragment := range knownUnsupportedMessages {
if strings.Contains(normalizedMessage, fragment) {
return true
}
}
return false
}
func TestIsUnsupportedEmptyContentAPIError(t *testing.T) { func TestIsUnsupportedEmptyContentAPIError(t *testing.T) {
t.Parallel() t.Parallel()
@@ -297,7 +272,7 @@ func TestIsUnsupportedEmptyContentAPIError(t *testing.T) {
tc := tc tc := tc
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
if got := isUnsupportedEmptyContentAPIError(tc.err); got != tc.expect { if got := isUnsupportedEmptyContentError(tc.err); got != tc.expect {
t.Fatalf("unexpected result for %q: got %v, want %v", tc.name, got, tc.expect) t.Fatalf("unexpected result for %q: got %v, want %v", tc.name, got, tc.expect)
} }
}) })
+210 -33
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/netip"
"strconv" "strconv"
"strings" "strings"
@@ -34,6 +35,7 @@ type dnsRecordResourceModel struct {
Hostname types.String `tfsdk:"hostname"` Hostname types.String `tfsdk:"hostname"`
RecordType types.String `tfsdk:"record_type"` RecordType types.String `tfsdk:"record_type"`
Content types.String `tfsdk:"content"` Content types.String `tfsdk:"content"`
Dynamic types.Bool `tfsdk:"dynamic"`
TTL types.Int64 `tfsdk:"ttl"` TTL types.Int64 `tfsdk:"ttl"`
State types.Bool `tfsdk:"state"` State types.Bool `tfsdk:"state"`
Group types.String `tfsdk:"group"` Group types.String `tfsdk:"group"`
@@ -66,7 +68,8 @@ 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)}}, "record_type": schema.StringAttribute{Required: true, Description: "DNS record type (A, AAAA, CNAME, TXT, etc.).", Validators: []validator.String{stringvalidator.LengthAtLeast(1)}},
"content": schema.StringAttribute{Optional: true, Description: "Record content/value."}, "content": schema.StringAttribute{Optional: true, Computed: true, Description: "Record content/value for static records. Omit for A/AAAA dynamic intent."},
"dynamic": schema.BoolAttribute{Optional: true, Computed: true, Description: "Whether A/AAAA should use Dynu dynamic IP semantics. Defaults to true when content is omitted for A/AAAA."},
"ttl": schema.Int64Attribute{ "ttl": schema.Int64Attribute{
Optional: true, Optional: true,
Computed: true, Computed: true,
@@ -107,18 +110,12 @@ func (r *dnsRecordResource) ValidateConfig(ctx context.Context, req resource.Val
if skip { if skip {
return return
} }
if recordType == "A" || recordType == "AAAA" { content := stringPointerFromOptionalContent(config.Content)
dynamicIntent, ok := resolveDynamicIntent(recordType, config.Content, config.Dynamic, &resp.Diagnostics)
if !ok {
return return
} }
if config.Content.IsUnknown() { validateDNSRecordContentForType(recordType, content, dynamicIntent, &resp.Diagnostics)
return
}
if config.Content.IsNull() || strings.TrimSpace(config.Content.ValueString()) == "" {
resp.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 use A/AAAA when content should be omitted.", recordType),
)
}
} }
func (r *dnsRecordResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { func (r *dnsRecordResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
@@ -134,29 +131,37 @@ func (r *dnsRecordResource) Create(ctx context.Context, req resource.CreateReque
return return
} }
recordType := strings.TrimSpace(plan.RecordType.ValueString())
dynamicIntent, ok := resolveDynamicIntent(strings.ToUpper(recordType), plan.Content, plan.Dynamic, &resp.Diagnostics)
if !ok {
return
}
createReq := dynuclient.CreateDNSRecordRequest{ createReq := dynuclient.CreateDNSRecordRequest{
NodeName: recordNodeName(plan.NodeName, plan.Hostname, domainName), NodeName: recordNodeName(plan.NodeName, plan.Hostname, domainName),
RecordType: strings.TrimSpace(plan.RecordType.ValueString()), RecordType: recordType,
Content: stringPointerFromOptional(plan.Content), Content: stringPointerFromOptionalContent(plan.Content),
TTL: int64FromOptional(plan.TTL), TTL: int64FromOptional(plan.TTL),
State: boolPointerFromOptional(plan.State), State: boolPointerFromOptional(plan.State),
Group: stringFromOptional(plan.Group), Group: stringFromOptional(plan.Group),
Host: stringFromOptional(plan.Host), Host: stringFromOptional(plan.Host),
} }
if !validateDNSRecordContentForType(createReq.RecordType, createReq.Content, &resp.Diagnostics) { if !validateDNSRecordContentForType(createReq.RecordType, createReq.Content, dynamicIntent, &resp.Diagnostics) {
return return
} }
record, err := r.clientProvider.client.CreateDNSRecord(ctx, domainID, createReq) record, err := r.clientProvider.client.CreateDNSRecord(ctx, domainID, createReq)
if err != nil && dynamicIntent && isUnsupportedEmptyContentError(err) {
if retryReq, retryOK := r.applyDynamicBootstrapFallback(ctx, domainID, createReq, &resp.Diagnostics); retryOK {
record, err = r.clientProvider.client.CreateDNSRecord(ctx, domainID, retryReq)
}
}
if err != nil { if err != nil {
addDNSRecordWriteDiagnostic("create", createReq.RecordType, createReq.Content, err, &resp.Diagnostics) addDNSRecordWriteDiagnostic("create", createReq.RecordType, createReq.Content, err, &resp.Diagnostics)
return return
} }
state := mapDNSRecordToState(*record) state := mapDNSRecordToState(*record, dynamicIntent)
if plan.Content.IsNull() || plan.Content.IsUnknown() {
state.Content = types.StringNull()
}
state.ID = types.StringValue(formatDNSRecordID(record.DomainID, record.ID)) state.ID = types.StringValue(formatDNSRecordID(record.DomainID, record.ID))
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
} }
@@ -185,10 +190,11 @@ func (r *dnsRecordResource) Read(ctx context.Context, req resource.ReadRequest,
return return
} }
nextState := mapDNSRecordToState(*record) dynamicIntent := false
if state.Content.IsNull() { if !state.Dynamic.IsNull() && !state.Dynamic.IsUnknown() {
nextState.Content = types.StringNull() dynamicIntent = state.Dynamic.ValueBool()
} }
nextState := mapDNSRecordToState(*record, dynamicIntent)
nextState.ID = state.ID nextState.ID = state.ID
resp.Diagnostics.Append(resp.State.Set(ctx, &nextState)...) resp.Diagnostics.Append(resp.State.Set(ctx, &nextState)...)
} }
@@ -216,23 +222,46 @@ func (r *dnsRecordResource) Update(ctx context.Context, req resource.UpdateReque
domainName = resolvedDomainName domainName = resolvedDomainName
} }
recordType := strings.TrimSpace(plan.RecordType.ValueString())
dynamicIntent, ok := resolveDynamicIntent(strings.ToUpper(recordType), plan.Content, plan.Dynamic, &resp.Diagnostics)
if !ok {
return
}
updateReq := dynuclient.UpdateDNSRecordRequest{ updateReq := dynuclient.UpdateDNSRecordRequest{
NodeName: recordNodeName(plan.NodeName, plan.Hostname, domainName), NodeName: recordNodeName(plan.NodeName, plan.Hostname, domainName),
RecordType: strings.TrimSpace(plan.RecordType.ValueString()), RecordType: recordType,
Content: stringPointerFromOptional(plan.Content), Content: stringPointerFromOptionalContent(plan.Content),
TTL: int64FromOptional(plan.TTL), TTL: int64FromOptional(plan.TTL),
State: boolPointerFromOptional(plan.State), State: boolPointerFromOptional(plan.State),
Group: stringFromOptional(plan.Group), Group: stringFromOptional(plan.Group),
Host: stringFromOptional(plan.Host), Host: stringFromOptional(plan.Host),
} }
if !validateDNSRecordContentForType(updateReq.RecordType, updateReq.Content, &resp.Diagnostics) { if !validateDNSRecordContentForType(updateReq.RecordType, updateReq.Content, dynamicIntent, &resp.Diagnostics) {
return return
} }
if _, err := r.clientProvider.client.UpdateDNSRecord(ctx, domainID, recordID, updateReq); err != nil { if _, err := r.clientProvider.client.UpdateDNSRecord(ctx, domainID, recordID, updateReq); err != nil {
if dynamicIntent && isUnsupportedEmptyContentError(err) {
if retryReq, retryOK := r.applyDynamicBootstrapFallback(ctx, domainID, dynuclient.CreateDNSRecordRequest{
NodeName: updateReq.NodeName,
RecordType: updateReq.RecordType,
Content: updateReq.Content,
TTL: updateReq.TTL,
State: updateReq.State,
Group: updateReq.Group,
Host: updateReq.Host,
}, &resp.Diagnostics); retryOK {
updateReq.Content = retryReq.Content
updateReq.Group = retryReq.Group
_, err = r.clientProvider.client.UpdateDNSRecord(ctx, domainID, recordID, updateReq)
}
}
if err != nil {
addDNSRecordWriteDiagnostic("update", updateReq.RecordType, updateReq.Content, err, &resp.Diagnostics) addDNSRecordWriteDiagnostic("update", updateReq.RecordType, updateReq.Content, err, &resp.Diagnostics)
return return
} }
}
record, err := r.clientProvider.client.GetDNSRecord(ctx, domainID, recordID) record, err := r.clientProvider.client.GetDNSRecord(ctx, domainID, recordID)
if err != nil { if err != nil {
@@ -240,10 +269,7 @@ func (r *dnsRecordResource) Update(ctx context.Context, req resource.UpdateReque
return return
} }
nextState := mapDNSRecordToState(*record) nextState := mapDNSRecordToState(*record, dynamicIntent)
if plan.Content.IsNull() || plan.Content.IsUnknown() {
nextState.Content = types.StringNull()
}
nextState.ID = types.StringValue(formatDNSRecordID(record.DomainID, record.ID)) nextState.ID = types.StringValue(formatDNSRecordID(record.DomainID, record.ID))
resp.Diagnostics.Append(resp.State.Set(ctx, &nextState)...) resp.Diagnostics.Append(resp.State.Set(ctx, &nextState)...)
} }
@@ -283,11 +309,13 @@ func (r *dnsRecordResource) ImportState(ctx context.Context, req resource.Import
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
} }
func mapDNSRecordToState(record dynuclient.DNSRecord) dnsRecordResourceModel { func mapDNSRecordToState(record dynuclient.DNSRecord, dynamicIntent bool) dnsRecordResourceModel {
content := normalizeRecordContentForState(record.RecordType, record.Content, dynamicIntent)
return dnsRecordResourceModel{ return dnsRecordResourceModel{
Hostname: mapString(record.Hostname), Hostname: mapString(record.Hostname),
RecordType: mapString(record.RecordType), RecordType: mapString(record.RecordType),
Content: mapString(record.Content), Content: content,
Dynamic: types.BoolValue(dynamicIntent),
TTL: types.Int64Value(record.TTL), TTL: types.Int64Value(record.TTL),
State: types.BoolValue(record.State), State: types.BoolValue(record.State),
Group: mapString(record.Group), Group: mapString(record.Group),
@@ -357,21 +385,65 @@ func stringFromOptional(value types.String) string {
return strings.TrimSpace(value.ValueString()) return strings.TrimSpace(value.ValueString())
} }
func stringPointerFromOptional(value types.String) *string { func stringPointerFromOptionalContent(value types.String) *string {
if value.IsNull() || value.IsUnknown() { if value.IsNull() || value.IsUnknown() {
return nil return nil
} }
trimmed := strings.TrimSpace(value.ValueString()) trimmed := strings.TrimSpace(value.ValueString())
if trimmed == "" {
return nil
}
return &trimmed return &trimmed
} }
func validateDNSRecordContentForType(recordType string, content *string, diagnostics *diag.Diagnostics) bool { func stringPointer(value string) *string {
return &value
}
func validateDNSRecordContentForType(recordType string, content *string, dynamicIntent bool, diagnostics *diag.Diagnostics) bool {
normalizedType := strings.ToUpper(strings.TrimSpace(recordType)) normalizedType := strings.ToUpper(strings.TrimSpace(recordType))
trimmedContent := ""
if content != nil {
trimmedContent = strings.TrimSpace(*content)
}
if normalizedType == "A" || normalizedType == "AAAA" { if normalizedType == "A" || normalizedType == "AAAA" {
if dynamicIntent && trimmedContent == "" {
return true
}
if trimmedContent == "" {
diagnostics.AddError(
"Missing static content for DNS record type",
fmt.Sprintf("The %q record type requires a non-empty content value unless dynamic mode is used.", normalizedType),
)
return false
}
addr, err := netip.ParseAddr(trimmedContent)
if err != nil {
diagnostics.AddError("Invalid DNS record content", fmt.Sprintf("Record type %q requires a valid IP address, got %q.", normalizedType, trimmedContent))
return false
}
if normalizedType == "A" && !addr.Is4() {
diagnostics.AddError("Invalid DNS record content", fmt.Sprintf("Record type %q requires an IPv4 address, got %q.", normalizedType, trimmedContent))
return false
}
if normalizedType == "AAAA" && !addr.Is6() {
diagnostics.AddError("Invalid DNS record content", fmt.Sprintf("Record type %q requires an IPv6 address, got %q.", normalizedType, trimmedContent))
return false
}
return true return true
} }
if content == nil || strings.TrimSpace(*content) == "" { if dynamicIntent {
diagnostics.AddError(
"Dynamic mode is only supported for A and AAAA",
fmt.Sprintf("The %q record type does not support omitted content.", normalizedType),
)
return false
}
if trimmedContent == "" {
diagnostics.AddError( diagnostics.AddError(
"Missing required content for DNS record type", "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), 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),
@@ -382,6 +454,111 @@ func validateDNSRecordContentForType(recordType string, content *string, diagnos
return true return true
} }
func resolveDynamicIntent(recordType string, content types.String, dynamic types.Bool, diagnostics *diag.Diagnostics) (bool, bool) {
normalizedType := strings.ToUpper(strings.TrimSpace(recordType))
contentPtr := stringPointerFromOptionalContent(content)
contentPresent := contentPtr != nil && strings.TrimSpace(*contentPtr) != ""
explicitDynamic := false
if !dynamic.IsNull() && !dynamic.IsUnknown() {
explicitDynamic = dynamic.ValueBool()
}
if normalizedType != "A" && normalizedType != "AAAA" {
if explicitDynamic {
diagnostics.AddError("Invalid dynamic setting", fmt.Sprintf("record_type %q cannot use dynamic = true.", normalizedType))
return false, false
}
return false, true
}
if contentPresent && explicitDynamic {
diagnostics.AddError("Conflicting DNS record settings", "Set either content for static records or dynamic = true/omitted content for Dynu dynamic behavior.")
return false, false
}
if contentPresent {
return false, true
}
if explicitDynamic {
return true, true
}
return true, true
}
func normalizeRecordContentForState(recordType string, content string, dynamicIntent bool) types.String {
if dynamicIntent {
return types.StringNull()
}
normalizedType := strings.ToUpper(strings.TrimSpace(recordType))
trimmed := strings.TrimSpace(strings.Trim(strings.TrimSpace(content), "()"))
if trimmed == "" {
return types.StringNull()
}
switch normalizedType {
case "AAAA":
if addr, err := netip.ParseAddr(trimmed); err == nil && addr.Is6() {
return types.StringValue(addr.String())
}
case "A":
if addr, err := netip.ParseAddr(trimmed); err == nil && addr.Is4() {
return types.StringValue(addr.String())
}
case "CNAME":
return types.StringValue(strings.TrimSuffix(trimmed, "."))
}
return types.StringValue(trimmed)
}
func isUnsupportedEmptyContentError(err error) bool {
var apiErr *dynuclient.APIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != 400 {
return false
}
normalizedType := strings.ToLower(strings.TrimSpace(apiErr.Type))
if normalizedType != "validation exception" {
return false
}
normalizedMessage := strings.ToLower(strings.TrimSpace(apiErr.Message))
return strings.Contains(normalizedMessage, "content is required") ||
strings.Contains(normalizedMessage, "ipv4address is required") ||
strings.Contains(normalizedMessage, "ipv6address is required") ||
strings.Contains(normalizedMessage, "invalid ip address")
}
func (r *dnsRecordResource) applyDynamicBootstrapFallback(ctx context.Context, domainID int64, req dynuclient.CreateDNSRecordRequest, diagnostics *diag.Diagnostics) (dynuclient.CreateDNSRecordRequest, bool) {
if req.Content != nil {
return req, true
}
domain, err := r.clientProvider.client.GetDomainByID(ctx, domainID)
if err != nil {
diagnostics.AddError(diagnosticSummary("Unable to fetch Dynu domain for dynamic fallback", err), err.Error())
return req, false
}
switch strings.ToUpper(strings.TrimSpace(req.RecordType)) {
case "A":
if strings.TrimSpace(domain.IPv4Address) == "" {
diagnostics.AddError("Unable to emulate dynamic A record", "Dynu rejected omitted IPv4 content and the root domain has no IPv4 address to bootstrap from.")
return req, false
}
req.Content = stringPointer(strings.TrimSpace(domain.IPv4Address))
case "AAAA":
if strings.TrimSpace(domain.IPv6Address) == "" {
diagnostics.AddError("Unable to emulate dynamic AAAA record", "Dynu rejected omitted IPv6 content and the root domain has no IPv6 address to bootstrap from.")
return req, false
}
req.Content = stringPointer(strings.TrimSpace(domain.IPv6Address))
default:
return req, true
}
if strings.TrimSpace(req.Group) == "" {
req.Group = strings.TrimSpace(domain.Group)
}
return req, true
}
func addDNSRecordWriteDiagnostic(operation string, recordType string, content *string, err error, diagnostics *diag.Diagnostics) { func addDNSRecordWriteDiagnostic(operation string, recordType string, content *string, err error, diagnostics *diag.Diagnostics) {
detail := err.Error() detail := err.Error()
var apiErr *dynuclient.APIError var apiErr *dynuclient.APIError
@@ -142,6 +142,83 @@ func TestIntegrationResourceDNSRecordImportStateInvalidID(t *testing.T) {
} }
} }
func TestIntegrationResourceDNSRecordDynamicAStateStableAndTransitionToStatic(t *testing.T) {
ctx := context.Background()
fake := fakedynu.NewServer()
defer fake.Close()
r := NewDNSRecordResource().(*dnsRecordResource)
configureResource(t, r, fake.BaseURL())
var schemaResp resource.SchemaResponse
r.Schema(ctx, resource.SchemaRequest{}, &schemaResp)
createPlan := dnsRecordResourceModel{
Hostname: types.StringValue("api.a.example.com"),
RecordType: types.StringValue("A"),
Content: types.StringNull(),
TTL: types.Int64Value(60),
State: types.BoolValue(true),
}
plan := tfsdk.Plan{Schema: schemaResp.Schema}
if diags := plan.Set(ctx, &createPlan); diags.HasError() {
t.Fatalf("set plan diagnostics: %v", diags)
}
createResp := resource.CreateResponse{State: tfsdk.State{Schema: schemaResp.Schema}}
r.Create(ctx, resource.CreateRequest{Plan: plan}, &createResp)
if createResp.Diagnostics.HasError() {
t.Fatalf("create diagnostics: %v", createResp.Diagnostics)
}
var state dnsRecordResourceModel
if diags := createResp.State.Get(ctx, &state); diags.HasError() {
t.Fatalf("state get diagnostics: %v", diags)
}
if !state.Dynamic.ValueBool() {
t.Fatalf("expected dynamic=true for blank A, got %#v", state.Dynamic)
}
if !state.Content.IsNull() {
t.Fatalf("expected dynamic A content to remain null in state, got %q", state.Content.ValueString())
}
readResp := resource.ReadResponse{State: createResp.State}
r.Read(ctx, resource.ReadRequest{State: createResp.State}, &readResp)
if readResp.Diagnostics.HasError() {
t.Fatalf("read diagnostics: %v", readResp.Diagnostics)
}
if diags := readResp.State.Get(ctx, &state); diags.HasError() {
t.Fatalf("read state diagnostics: %v", diags)
}
if !state.Content.IsNull() {
t.Fatalf("expected dynamic A read to preserve null content, got %q", state.Content.ValueString())
}
updatePlan := state
updatePlan.Content = types.StringValue("192.0.2.42")
updatePlan.Dynamic = types.BoolValue(false)
plan = tfsdk.Plan{Schema: schemaResp.Schema}
if diags := plan.Set(ctx, &updatePlan); diags.HasError() {
t.Fatalf("set update plan diagnostics: %v", diags)
}
updateResp := resource.UpdateResponse{State: tfsdk.State{Schema: schemaResp.Schema}}
r.Update(ctx, resource.UpdateRequest{Plan: plan}, &updateResp)
if updateResp.Diagnostics.HasError() {
t.Fatalf("update diagnostics: %v", updateResp.Diagnostics)
}
if diags := updateResp.State.Get(ctx, &state); diags.HasError() {
t.Fatalf("updated state diagnostics: %v", diags)
}
if state.Dynamic.ValueBool() {
t.Fatalf("expected static A after setting content")
}
if state.Content.ValueString() != "192.0.2.42" {
t.Fatalf("expected static content after update, got %q", state.Content.ValueString())
}
}
func configureResource(t *testing.T, r resource.ResourceWithConfigure, baseURL string) { func configureResource(t *testing.T, r resource.ResourceWithConfigure, baseURL string) {
t.Helper() t.Helper()
resp := resource.ConfigureResponse{} resp := resource.ConfigureResponse{}
+38 -3
View File
@@ -4,6 +4,7 @@ import (
"testing" "testing"
"github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/types"
) )
func TestParseDNSRecordID(t *testing.T) { func TestParseDNSRecordID(t *testing.T) {
@@ -23,6 +24,8 @@ func TestParseDNSRecordIDInvalid(t *testing.T) {
} }
func TestValidateDNSRecordContentForType(t *testing.T) { func TestValidateDNSRecordContentForType(t *testing.T) {
ipv4 := "192.0.2.123"
ipv6 := "2001:db8::123"
nonEmpty := "hello" nonEmpty := "hello"
blank := "" blank := ""
@@ -30,10 +33,16 @@ func TestValidateDNSRecordContentForType(t *testing.T) {
name string name string
recordType string recordType string
content *string content *string
dynamic bool
wantValid bool wantValid bool
}{ }{
{name: "A allows nil content", recordType: "A", content: nil, wantValid: true}, {name: "A accepts static ipv4", recordType: "A", content: &ipv4, wantValid: true},
{name: "AAAA allows nil content", recordType: "AAAA", content: nil, wantValid: true}, {name: "A rejects ipv6", recordType: "A", content: &ipv6, wantValid: false},
{name: "A accepts dynamic nil", recordType: "A", content: nil, dynamic: true, wantValid: true},
{name: "A accepts dynamic blank", recordType: "A", content: &blank, dynamic: true, wantValid: true},
{name: "AAAA accepts static ipv6", recordType: "AAAA", content: &ipv6, wantValid: true},
{name: "AAAA rejects ipv4", recordType: "AAAA", content: &ipv4, wantValid: false},
{name: "AAAA accepts dynamic nil", recordType: "AAAA", content: nil, dynamic: true, wantValid: true},
{name: "TXT requires content", recordType: "TXT", content: nil, wantValid: false}, {name: "TXT requires content", recordType: "TXT", content: nil, wantValid: false},
{name: "TXT rejects blank content", recordType: "TXT", content: &blank, 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}, {name: "TXT accepts non-empty content", recordType: "TXT", content: &nonEmpty, wantValid: true},
@@ -42,7 +51,7 @@ func TestValidateDNSRecordContentForType(t *testing.T) {
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
diags := diag.Diagnostics{} diags := diag.Diagnostics{}
got := validateDNSRecordContentForType(tc.recordType, tc.content, &diags) got := validateDNSRecordContentForType(tc.recordType, tc.content, tc.dynamic, &diags)
if got != tc.wantValid { if got != tc.wantValid {
t.Fatalf("validateDNSRecordContentForType()=%v, want %v", got, tc.wantValid) t.Fatalf("validateDNSRecordContentForType()=%v, want %v", got, tc.wantValid)
} }
@@ -55,3 +64,29 @@ func TestValidateDNSRecordContentForType(t *testing.T) {
}) })
} }
} }
func TestResolveDynamicIntent(t *testing.T) {
diags := diag.Diagnostics{}
dynamic, ok := resolveDynamicIntent("A", types.StringNull(), types.BoolNull(), &diags)
if !ok || !dynamic || diags.HasError() {
t.Fatalf("expected omitted A content to resolve to dynamic=true, got dynamic=%v ok=%v diags=%v", dynamic, ok, diags)
}
diags = diag.Diagnostics{}
dynamic, ok = resolveDynamicIntent("A", types.StringValue("192.0.2.10"), types.BoolNull(), &diags)
if !ok || dynamic || diags.HasError() {
t.Fatalf("expected static A content to resolve to dynamic=false, got dynamic=%v ok=%v diags=%v", dynamic, ok, diags)
}
}
func TestNormalizeRecordContentForState(t *testing.T) {
if got := normalizeRecordContentForState("AAAA", "2001:0db8:0000:0000:0000:0000:0000:0123", false); got.ValueString() != "2001:db8::123" {
t.Fatalf("expected canonical IPv6, got %q", got.ValueString())
}
if got := normalizeRecordContentForState("CNAME", "Example.COM.", false); got.ValueString() != "Example.COM" {
t.Fatalf("expected trailing dot removed, got %q", got.ValueString())
}
if got := normalizeRecordContentForState("A", "(167.179.167.166)", true); !got.IsNull() {
t.Fatalf("expected dynamic content to remain null, got %q", got.ValueString())
}
}
+5 -1
View File
@@ -268,7 +268,11 @@ func (s *Server) handleCreateRecord(w http.ResponseWriter, r *http.Request, doma
if !ok { if !ok {
return return
} }
if req.RecordType == "" || req.Content == "" { if req.RecordType == "" {
s.writeAPIError(w, APIError{HTTPStatus: http.StatusBadRequest, StatusCode: 400, Type: "Validation Exception", Message: "recordType is required"})
return
}
if req.Content == "" && !strings.EqualFold(req.RecordType, "A") && !strings.EqualFold(req.RecordType, "AAAA") {
s.writeAPIError(w, APIError{HTTPStatus: http.StatusBadRequest, StatusCode: 400, Type: "Validation Exception", Message: "recordType and content are required"}) s.writeAPIError(w, APIError{HTTPStatus: http.StatusBadRequest, StatusCode: 400, Type: "Validation Exception", Message: "recordType and content are required"})
return return
} }