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:
- `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`
+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>`)
2. `AAAA` record with IPv6 content (`codex-aaaa-<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>`)
5. **Blank `AAAA` record** with no content/IP (`codex-blank-aaaa-<suffix>.<root_domain>`)
4. **Dynamic `A` record** with omitted content (`codex-dynamic-a-<suffix>.<root_domain>`)
5. **Dynamic `AAAA` record** with omitted content (`codex-dynamic-aaaa-<suffix>.<root_domain>`)
> [!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-<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_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
+11 -11
View File
@@ -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
}
}
}
+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 {
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) {
+2 -27
View File
@@ -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)
}
})
+212 -35
View File
@@ -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
@@ -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{}
+38 -3
View File
@@ -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())
}
}
+5 -1
View File
@@ -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
}