Archived
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:
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user