diff --git a/README.md b/README.md index ce15d8e..29c59b4 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,11 @@ Provider resources: Arguments: - `hostname` (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) - `state` (Bool, optional) - `group` (String, optional) @@ -181,8 +185,19 @@ resource "dynu_dns_record" "txt" { ttl = 300 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 ### `dynu_domains` diff --git a/examples/live_safe_dns_record/README.md b/examples/live_safe_dns_record/README.md index a746810..2546e9f 100644 --- a/examples/live_safe_dns_record/README.md +++ b/examples/live_safe_dns_record/README.md @@ -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-.`) 2. `AAAA` record with IPv6 content (`codex-aaaa-.`) 3. `CNAME` record (`codex-cname-.`) -4. **Blank `A` record** with no content/IP (`codex-blank-a-.`) -5. **Blank `AAAA` record** with no content/IP (`codex-blank-aaaa-.`) +4. **Dynamic `A` record** with omitted content (`codex-dynamic-a-.`) +5. **Dynamic `AAAA` record** with omitted content (`codex-dynamic-aaaa-.`) > [!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. @@ -52,5 +52,15 @@ terraform destroy - `dynu_dns_record.a_ipv4` - `dynu_dns_record.aaaa_ipv6` - `dynu_dns_record.cname` - - `dynu_dns_record.blank_a` - - `dynu_dns_record.blank_aaaa` + - `dynu_dns_record.dynamic_a` + - `dynu_dns_record.dynamic_aaaa` + +Verification commands: + +```bash +dig A codex-dynamic-a-. +dig AAAA codex-dynamic-aaaa-. +dig CNAME codex-cname-. +``` + +`dig ` defaults to an `A` lookup, which can be misleading for AAAA-only checks. diff --git a/examples/live_safe_dns_record/main.tf b/examples/live_safe_dns_record/main.tf index 5a91f2c..50fb634 100644 --- a/examples/live_safe_dns_record/main.tf +++ b/examples/live_safe_dns_record/main.tf @@ -9,8 +9,8 @@ locals { 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_cname = "codex-cname-${var.test_suffix}.${var.dynu_root_domain}" - hostname_blank_a = "codex-blank-a-${var.test_suffix}.${var.dynu_root_domain}" - hostname_blank_aaaa = "codex-blank-aaaa-${var.test_suffix}.${var.dynu_root_domain}" + hostname_dynamic_a = "codex-dynamic-a-${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" { @@ -37,17 +37,17 @@ resource "dynu_dns_record" "cname" { state = true } -# Deliberate blank A record scenario: A type with no content/IP value. -resource "dynu_dns_record" "blank_a" { - hostname = local.hostname_blank_a +# Deliberate dynamic A record scenario: content intentionally omitted for Dynu dynamic IPv4 behavior. +resource "dynu_dns_record" "dynamic_a" { + hostname = local.hostname_dynamic_a record_type = "A" ttl = var.test_ttl state = true } -# Deliberate blank AAAA record scenario: AAAA type with no content/IP value. -resource "dynu_dns_record" "blank_aaaa" { - hostname = local.hostname_blank_aaaa +# Deliberate dynamic AAAA record scenario: content intentionally omitted for Dynu dynamic IPv6 behavior. +resource "dynu_dns_record" "dynamic_aaaa" { + hostname = local.hostname_dynamic_aaaa record_type = "AAAA" ttl = var.test_ttl state = true diff --git a/examples/live_safe_dns_record/outputs.tf b/examples/live_safe_dns_record/outputs.tf index c827fcc..758343e 100644 --- a/examples/live_safe_dns_record/outputs.tf +++ b/examples/live_safe_dns_record/outputs.tf @@ -4,8 +4,8 @@ output "record_hostnames" { a_ipv4 = dynu_dns_record.a_ipv4.hostname aaaa_ipv6 = dynu_dns_record.aaaa_ipv6.hostname cname = dynu_dns_record.cname.hostname - blank_a = dynu_dns_record.blank_a.hostname - blank_aaaa = dynu_dns_record.blank_aaaa.hostname + dynamic_a = dynu_dns_record.dynamic_a.hostname + dynamic_aaaa = dynu_dns_record.dynamic_aaaa.hostname } } @@ -15,13 +15,13 @@ output "record_ids" { a_ipv4 = dynu_dns_record.a_ipv4.id aaaa_ipv6 = dynu_dns_record.aaaa_ipv6.id cname = dynu_dns_record.cname.id - blank_a = dynu_dns_record.blank_a.id - blank_aaaa = dynu_dns_record.blank_aaaa.id + dynamic_a = dynu_dns_record.dynamic_a.id + dynamic_aaaa = dynu_dns_record.dynamic_aaaa.id } } 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 = { a_ipv4 = { type = dynu_dns_record.a_ipv4.record_type @@ -35,13 +35,13 @@ output "record_values" { type = dynu_dns_record.cname.record_type content = dynu_dns_record.cname.content } - blank_a = { - type = dynu_dns_record.blank_a.record_type - content = dynu_dns_record.blank_a.content + dynamic_a = { + type = dynu_dns_record.dynamic_a.record_type + content = dynu_dns_record.dynamic_a.content } - blank_aaaa = { - type = dynu_dns_record.blank_aaaa.record_type - content = dynu_dns_record.blank_aaaa.content + dynamic_aaaa = { + type = dynu_dns_record.dynamic_aaaa.record_type + content = dynu_dns_record.dynamic_aaaa.content } } } diff --git a/internal/dynuclient/client.go b/internal/dynuclient/client.go index 47509f5..fd18afb 100644 --- a/internal/dynuclient/client.go +++ b/internal/dynuclient/client.go @@ -322,10 +322,11 @@ type dnsRecordUpsertPayload struct { } func buildDNSRecordUpsertPayload(recordType string, nodeName string, content *string, ttl int64, state *bool, group string, host string) dnsRecordUpsertPayload { + normalizedContent := normalizeOptionalContent(content) payload := dnsRecordUpsertPayload{ NodeName: nodeName, RecordType: recordType, - Content: content, + Content: normalizedContent, TTL: ttl, State: state, Group: group, @@ -334,22 +335,33 @@ func buildDNSRecordUpsertPayload(recordType string, nodeName string, content *st switch strings.ToUpper(strings.TrimSpace(recordType)) { case "A": - if content != nil { - payload.IPv4Address = *content + if normalizedContent != nil { + payload.IPv4Address = *normalizedContent } case "AAAA": - if content != nil { - payload.IPv6Address = *content + if normalizedContent != nil { + payload.IPv6Address = *normalizedContent } case "CNAME": - if payload.Host == "" && content != nil { - payload.Host = *content + if payload.Host == "" && normalizedContent != nil { + payload.Host = *normalizedContent } } 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+(.+)$`) func normalizeDNSRecord(record *DNSRecord) { diff --git a/internal/provider/provider_acc_test.go b/internal/provider/provider_acc_test.go index 50fc517..1f009af 100644 --- a/internal/provider/provider_acc_test.go +++ b/internal/provider/provider_acc_test.go @@ -192,7 +192,7 @@ func testAccCreateRecordMaybeSkipUnsupported(t *testing.T, client *dynuclient.Cl } 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) } @@ -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) { t.Parallel() @@ -297,7 +272,7 @@ func TestIsUnsupportedEmptyContentAPIError(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { 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) } }) diff --git a/internal/provider/resource_dns_record.go b/internal/provider/resource_dns_record.go index 5684339..972b75f 100644 --- a/internal/provider/resource_dns_record.go +++ b/internal/provider/resource_dns_record.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "net/netip" "strconv" "strings" @@ -34,6 +35,7 @@ type dnsRecordResourceModel struct { Hostname types.String `tfsdk:"hostname"` RecordType types.String `tfsdk:"record_type"` Content types.String `tfsdk:"content"` + Dynamic types.Bool `tfsdk:"dynamic"` TTL types.Int64 `tfsdk:"ttl"` State types.Bool `tfsdk:"state"` 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)}}, - "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{ Optional: true, Computed: true, @@ -107,18 +110,12 @@ func (r *dnsRecordResource) ValidateConfig(ctx context.Context, req resource.Val if skip { return } - if recordType == "A" || recordType == "AAAA" { + content := stringPointerFromOptionalContent(config.Content) + dynamicIntent, ok := resolveDynamicIntent(recordType, config.Content, config.Dynamic, &resp.Diagnostics) + if !ok { return } - if config.Content.IsUnknown() { - 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), - ) - } + validateDNSRecordContentForType(recordType, content, dynamicIntent, &resp.Diagnostics) } 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 } + recordType := strings.TrimSpace(plan.RecordType.ValueString()) + dynamicIntent, ok := resolveDynamicIntent(strings.ToUpper(recordType), plan.Content, plan.Dynamic, &resp.Diagnostics) + if !ok { + return + } + createReq := dynuclient.CreateDNSRecordRequest{ NodeName: recordNodeName(plan.NodeName, plan.Hostname, domainName), - RecordType: strings.TrimSpace(plan.RecordType.ValueString()), - Content: stringPointerFromOptional(plan.Content), + RecordType: recordType, + Content: stringPointerFromOptionalContent(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) { + if !validateDNSRecordContentForType(createReq.RecordType, createReq.Content, dynamicIntent, &resp.Diagnostics) { return } 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 { addDNSRecordWriteDiagnostic("create", createReq.RecordType, createReq.Content, err, &resp.Diagnostics) return } - state := mapDNSRecordToState(*record) - if plan.Content.IsNull() || plan.Content.IsUnknown() { - state.Content = types.StringNull() - } + state := mapDNSRecordToState(*record, dynamicIntent) state.ID = types.StringValue(formatDNSRecordID(record.DomainID, record.ID)) resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } @@ -185,10 +190,11 @@ func (r *dnsRecordResource) Read(ctx context.Context, req resource.ReadRequest, return } - nextState := mapDNSRecordToState(*record) - if state.Content.IsNull() { - nextState.Content = types.StringNull() + dynamicIntent := false + if !state.Dynamic.IsNull() && !state.Dynamic.IsUnknown() { + dynamicIntent = state.Dynamic.ValueBool() } + nextState := mapDNSRecordToState(*record, dynamicIntent) nextState.ID = state.ID resp.Diagnostics.Append(resp.State.Set(ctx, &nextState)...) } @@ -216,22 +222,45 @@ func (r *dnsRecordResource) Update(ctx context.Context, req resource.UpdateReque 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{ NodeName: recordNodeName(plan.NodeName, plan.Hostname, domainName), - RecordType: strings.TrimSpace(plan.RecordType.ValueString()), - Content: stringPointerFromOptional(plan.Content), + RecordType: recordType, + Content: stringPointerFromOptionalContent(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) { + if !validateDNSRecordContentForType(updateReq.RecordType, updateReq.Content, dynamicIntent, &resp.Diagnostics) { return } if _, err := r.clientProvider.client.UpdateDNSRecord(ctx, domainID, recordID, updateReq); err != nil { - addDNSRecordWriteDiagnostic("update", updateReq.RecordType, updateReq.Content, err, &resp.Diagnostics) - return + 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) + return + } } record, err := r.clientProvider.client.GetDNSRecord(ctx, domainID, recordID) @@ -240,10 +269,7 @@ func (r *dnsRecordResource) Update(ctx context.Context, req resource.UpdateReque return } - nextState := mapDNSRecordToState(*record) - if plan.Content.IsNull() || plan.Content.IsUnknown() { - nextState.Content = types.StringNull() - } + nextState := mapDNSRecordToState(*record, dynamicIntent) nextState.ID = types.StringValue(formatDNSRecordID(record.DomainID, record.ID)) 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)...) } -func mapDNSRecordToState(record dynuclient.DNSRecord) dnsRecordResourceModel { +func mapDNSRecordToState(record dynuclient.DNSRecord, dynamicIntent bool) dnsRecordResourceModel { + content := normalizeRecordContentForState(record.RecordType, record.Content, dynamicIntent) return dnsRecordResourceModel{ Hostname: mapString(record.Hostname), RecordType: mapString(record.RecordType), - Content: mapString(record.Content), + Content: content, + Dynamic: types.BoolValue(dynamicIntent), TTL: types.Int64Value(record.TTL), State: types.BoolValue(record.State), Group: mapString(record.Group), @@ -357,21 +385,65 @@ func stringFromOptional(value types.String) string { return strings.TrimSpace(value.ValueString()) } -func stringPointerFromOptional(value types.String) *string { +func stringPointerFromOptionalContent(value types.String) *string { if value.IsNull() || value.IsUnknown() { return nil } trimmed := strings.TrimSpace(value.ValueString()) + if trimmed == "" { + return nil + } 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)) + trimmedContent := "" + if content != nil { + trimmedContent = strings.TrimSpace(*content) + } + 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 } - 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( "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), @@ -382,6 +454,111 @@ func validateDNSRecordContentForType(recordType string, content *string, diagnos 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) { detail := err.Error() var apiErr *dynuclient.APIError diff --git a/internal/provider/resource_dns_record_integration_test.go b/internal/provider/resource_dns_record_integration_test.go index e3d049d..edacb9f 100644 --- a/internal/provider/resource_dns_record_integration_test.go +++ b/internal/provider/resource_dns_record_integration_test.go @@ -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) { t.Helper() resp := resource.ConfigureResponse{} diff --git a/internal/provider/resource_dns_record_test.go b/internal/provider/resource_dns_record_test.go index f9a5247..3b9221d 100644 --- a/internal/provider/resource_dns_record_test.go +++ b/internal/provider/resource_dns_record_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" ) func TestParseDNSRecordID(t *testing.T) { @@ -23,6 +24,8 @@ func TestParseDNSRecordIDInvalid(t *testing.T) { } func TestValidateDNSRecordContentForType(t *testing.T) { + ipv4 := "192.0.2.123" + ipv6 := "2001:db8::123" nonEmpty := "hello" blank := "" @@ -30,10 +33,16 @@ func TestValidateDNSRecordContentForType(t *testing.T) { name string recordType string content *string + dynamic bool 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: "A accepts static ipv4", recordType: "A", content: &ipv4, 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 rejects blank content", recordType: "TXT", content: &blank, wantValid: false}, {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 { t.Run(tc.name, func(t *testing.T) { diags := diag.Diagnostics{} - got := validateDNSRecordContentForType(tc.recordType, tc.content, &diags) + got := validateDNSRecordContentForType(tc.recordType, tc.content, tc.dynamic, &diags) if 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()) + } +} diff --git a/internal/testutil/fakedynu/server.go b/internal/testutil/fakedynu/server.go index dc1a537..6bab80c 100644 --- a/internal/testutil/fakedynu/server.go +++ b/internal/testutil/fakedynu/server.go @@ -268,7 +268,11 @@ func (s *Server) handleCreateRecord(w http.ResponseWriter, r *http.Request, doma if !ok { 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"}) return }