Archived
Merge pull request #46 from beatz174-bit/codex/harden-a,-aaaa,-cname-record-support
Add `enabled` and `location` fields, enforce TTL >= 90s, and wire location through client/payload
This commit is contained in:
@@ -163,9 +163,10 @@ Arguments:
|
|||||||
- `dynamic` (Bool, optional/computed)
|
- `dynamic` (Bool, optional/computed)
|
||||||
- Explicit dynamic-mode toggle for `A`/`AAAA`. Existing omitted `content` behavior remains backward compatible.
|
- Explicit dynamic-mode toggle for `A`/`AAAA`. Existing omitted `content` behavior remains backward compatible.
|
||||||
- `ttl` (Number, optional)
|
- `ttl` (Number, optional)
|
||||||
- `state` (Bool, optional)
|
- `enabled` (Bool, optional, defaults to `true`)
|
||||||
- `group` (String, optional)
|
- `group` (String, optional)
|
||||||
- `host` (String, optional)
|
- `host` (String, optional)
|
||||||
|
- `location` (String, optional; A/AAAA only)
|
||||||
- `node_name` (String, optional)
|
- `node_name` (String, optional)
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
@@ -180,10 +181,11 @@ Example:
|
|||||||
```hcl
|
```hcl
|
||||||
resource "dynu_dns_record" "txt" {
|
resource "dynu_dns_record" "txt" {
|
||||||
hostname = "api.example.com"
|
hostname = "api.example.com"
|
||||||
record_type = "TXT"
|
record_type = "A"
|
||||||
content = "hello-from-terraform"
|
content = "198.51.100.10"
|
||||||
ttl = 300
|
ttl = 90
|
||||||
state = true
|
enabled = false
|
||||||
|
location = "us"
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "dynu_dns_record" "dynamic_a" {
|
resource "dynu_dns_record" "dynamic_a" {
|
||||||
@@ -244,7 +246,7 @@ Attributes:
|
|||||||
- `domain_name` (String)
|
- `domain_name` (String)
|
||||||
- `records` (List(Object)) with:
|
- `records` (List(Object)) with:
|
||||||
- `id`, `domain_id`, `domain_name`, `node_name`, `hostname`, `record_type`
|
- `id`, `domain_id`, `domain_name`, `node_name`, `hostname`, `record_type`
|
||||||
- `ttl`, `state`, `content`, `updated_on`, `group`, `host`
|
- `ttl`, `state`, `content`, `updated_on`, `group`, `host`, `location`
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ This example is an **opt-in live test** for writable Dynu provider functionality
|
|||||||
|
|
||||||
Using a single suffix (`test_suffix`), this example creates five DNS record scenarios under `dynu_root_domain`:
|
Using a single suffix (`test_suffix`), this example creates five DNS record scenarios under `dynu_root_domain`:
|
||||||
|
|
||||||
1. `A` record with IPv4 content (`codex-a-<suffix>.<root_domain>`)
|
1. `A` record with IPv4 content, `location`, and minimum TTL=90 (`codex-a-<suffix>.<root_domain>`)
|
||||||
2. `AAAA` record with IPv6 content (`codex-aaaa-<suffix>.<root_domain>`)
|
2. `AAAA` record with IPv6 content and `location` (`codex-aaaa-<suffix>.<root_domain>`)
|
||||||
3. `CNAME` record (`codex-cname-<suffix>.<root_domain>`)
|
3. `CNAME` record created disabled (`enabled = false`) (`codex-cname-<suffix>.<root_domain>`)
|
||||||
4. **Dynamic `A` record** with omitted content (`codex-dynamic-a-<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>`)
|
5. **Dynamic `AAAA` record** with omitted content (`codex-dynamic-aaaa-<suffix>.<root_domain>`)
|
||||||
|
|
||||||
@@ -32,7 +32,10 @@ Edit `terraform.tfvars` and set at least:
|
|||||||
- `dynu_root_domain`
|
- `dynu_root_domain`
|
||||||
- `test_suffix` (use a unique value per run)
|
- `test_suffix` (use a unique value per run)
|
||||||
|
|
||||||
Optional overrides include `test_ipv4`, `test_ipv6`, and `test_cname_target`. Use real routable IPs for `test_ipv4`/`test_ipv6`; Dynu rejects documentation ranges such as `192.0.2.0/24` and `2001:db8::/32`.
|
Optional overrides include `test_ipv4`, `test_ipv6`, `test_cname_target`, `test_ttl`, and `test_location`.
|
||||||
|
|
||||||
|
- `test_ttl` must be `0` (provider/API default) or `>= 90`.
|
||||||
|
- `test_location` applies only to A/AAAA records.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
@@ -46,7 +49,7 @@ terraform destroy
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- `terraform apply` should create all five record scenarios.
|
- `terraform apply` should create all five record scenarios (including a disabled CNAME example).
|
||||||
- `terraform destroy` should remove all five records created by this state.
|
- `terraform destroy` should remove all five records created by this state.
|
||||||
- If you need to target a single scenario, resources are explicitly named:
|
- If you need to target a single scenario, resources are explicitly named:
|
||||||
- `dynu_dns_record.a_ipv4`
|
- `dynu_dns_record.a_ipv4`
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ resource "dynu_dns_record" "a_ipv4" {
|
|||||||
hostname = local.hostname_a_ipv4
|
hostname = local.hostname_a_ipv4
|
||||||
record_type = "A"
|
record_type = "A"
|
||||||
content = var.test_ipv4
|
content = var.test_ipv4
|
||||||
ttl = var.test_ttl
|
ttl = 90
|
||||||
state = true
|
enabled = true
|
||||||
|
location = var.test_location
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "dynu_dns_record" "aaaa_ipv6" {
|
resource "dynu_dns_record" "aaaa_ipv6" {
|
||||||
@@ -26,7 +27,8 @@ resource "dynu_dns_record" "aaaa_ipv6" {
|
|||||||
record_type = "AAAA"
|
record_type = "AAAA"
|
||||||
content = var.test_ipv6
|
content = var.test_ipv6
|
||||||
ttl = var.test_ttl
|
ttl = var.test_ttl
|
||||||
state = true
|
enabled = true
|
||||||
|
location = var.test_location
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "dynu_dns_record" "cname" {
|
resource "dynu_dns_record" "cname" {
|
||||||
@@ -34,7 +36,7 @@ resource "dynu_dns_record" "cname" {
|
|||||||
record_type = "CNAME"
|
record_type = "CNAME"
|
||||||
content = var.test_cname_target
|
content = var.test_cname_target
|
||||||
ttl = var.test_ttl
|
ttl = var.test_ttl
|
||||||
state = true
|
enabled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
# Deliberate dynamic A record scenario: content intentionally omitted for Dynu dynamic IPv4 behavior.
|
# Deliberate dynamic A record scenario: content intentionally omitted for Dynu dynamic IPv4 behavior.
|
||||||
@@ -42,7 +44,7 @@ resource "dynu_dns_record" "dynamic_a" {
|
|||||||
hostname = local.hostname_dynamic_a
|
hostname = local.hostname_dynamic_a
|
||||||
record_type = "A"
|
record_type = "A"
|
||||||
ttl = var.test_ttl
|
ttl = var.test_ttl
|
||||||
state = true
|
enabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
# Deliberate dynamic AAAA record scenario: content intentionally omitted for Dynu dynamic IPv6 behavior.
|
# Deliberate dynamic AAAA record scenario: content intentionally omitted for Dynu dynamic IPv6 behavior.
|
||||||
@@ -50,5 +52,5 @@ resource "dynu_dns_record" "dynamic_aaaa" {
|
|||||||
hostname = local.hostname_dynamic_aaaa
|
hostname = local.hostname_dynamic_aaaa
|
||||||
record_type = "AAAA"
|
record_type = "AAAA"
|
||||||
ttl = var.test_ttl
|
ttl = var.test_ttl
|
||||||
state = true
|
enabled = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,3 +14,4 @@ test_ipv6 = "2001:db8::123"
|
|||||||
test_cname_target = "example.com"
|
test_cname_target = "example.com"
|
||||||
|
|
||||||
test_ttl = 300
|
test_ttl = 300
|
||||||
|
test_location = "us"
|
||||||
|
|||||||
@@ -60,7 +60,13 @@ variable "test_cname_target" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
variable "test_ttl" {
|
variable "test_ttl" {
|
||||||
description = "TTL in seconds for disposable DNS records."
|
description = "TTL in seconds for disposable DNS records. Must be 0 (provider/API default) or >= 90."
|
||||||
type = number
|
type = number
|
||||||
default = 300
|
default = 300
|
||||||
}
|
}
|
||||||
|
|
||||||
|
variable "test_location" {
|
||||||
|
description = "Optional Dynu location hint for A/AAAA records."
|
||||||
|
type = string
|
||||||
|
default = "us"
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
resource "dynu_dns_record" "txt" {
|
resource "dynu_dns_record" "a_location_min_ttl" {
|
||||||
hostname = "api.example.com"
|
hostname = "api.example.com"
|
||||||
record_type = "TXT"
|
record_type = "A"
|
||||||
content = "managed-by-terraform"
|
content = "198.51.100.10"
|
||||||
ttl = 300
|
ttl = 90
|
||||||
state = true
|
enabled = false
|
||||||
|
location = "us"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ type DNSRecord struct {
|
|||||||
UpdatedOn string `json:"updatedOn"`
|
UpdatedOn string `json:"updatedOn"`
|
||||||
Group string `json:"group"`
|
Group string `json:"group"`
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
|
Location string `json:"location"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateDNSRecordRequest struct {
|
type CreateDNSRecordRequest struct {
|
||||||
@@ -116,6 +117,7 @@ type CreateDNSRecordRequest struct {
|
|||||||
State *bool `json:"state,omitempty"`
|
State *bool `json:"state,omitempty"`
|
||||||
Group string `json:"group,omitempty"`
|
Group string `json:"group,omitempty"`
|
||||||
Host string `json:"host,omitempty"`
|
Host string `json:"host,omitempty"`
|
||||||
|
Location string `json:"location,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateDNSRecordRequest struct {
|
type UpdateDNSRecordRequest struct {
|
||||||
@@ -126,6 +128,7 @@ type UpdateDNSRecordRequest struct {
|
|||||||
State *bool `json:"state,omitempty"`
|
State *bool `json:"state,omitempty"`
|
||||||
Group string `json:"group,omitempty"`
|
Group string `json:"group,omitempty"`
|
||||||
Host string `json:"host,omitempty"`
|
Host string `json:"host,omitempty"`
|
||||||
|
Location string `json:"location,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type listDomainsResponse struct {
|
type listDomainsResponse struct {
|
||||||
@@ -207,7 +210,7 @@ func (c *Client) GetDNSRecord(ctx context.Context, domainID int64, recordID int6
|
|||||||
|
|
||||||
func (c *Client) CreateDNSRecord(ctx context.Context, domainID int64, req CreateDNSRecordRequest) (*DNSRecord, error) {
|
func (c *Client) CreateDNSRecord(ctx context.Context, domainID int64, req CreateDNSRecordRequest) (*DNSRecord, error) {
|
||||||
var resp getDNSRecordResponse
|
var resp getDNSRecordResponse
|
||||||
if err := c.doRequest(ctx, http.MethodPost, fmt.Sprintf("/dns/%d/record", domainID), buildDNSRecordUpsertPayload(req.RecordType, req.NodeName, req.Content, req.TTL, req.State, req.Group, req.Host), &resp); err != nil {
|
if err := c.doRequest(ctx, http.MethodPost, fmt.Sprintf("/dns/%d/record", domainID), buildDNSRecordUpsertPayload(req.RecordType, req.NodeName, req.Content, req.TTL, req.State, req.Group, req.Host, req.Location), &resp); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
normalizeDNSRecord(&resp.DNSRecord)
|
normalizeDNSRecord(&resp.DNSRecord)
|
||||||
@@ -216,7 +219,7 @@ func (c *Client) CreateDNSRecord(ctx context.Context, domainID int64, req Create
|
|||||||
|
|
||||||
func (c *Client) UpdateDNSRecord(ctx context.Context, domainID int64, recordID int64, req UpdateDNSRecordRequest) (*DNSRecord, error) {
|
func (c *Client) UpdateDNSRecord(ctx context.Context, domainID int64, recordID int64, req UpdateDNSRecordRequest) (*DNSRecord, error) {
|
||||||
var resp getDNSRecordResponse
|
var resp getDNSRecordResponse
|
||||||
if err := c.doRequest(ctx, http.MethodPost, fmt.Sprintf("/dns/%d/record/%d", domainID, recordID), buildDNSRecordUpsertPayload(req.RecordType, req.NodeName, req.Content, req.TTL, req.State, req.Group, req.Host), &resp); err != nil {
|
if err := c.doRequest(ctx, http.MethodPost, fmt.Sprintf("/dns/%d/record/%d", domainID, recordID), buildDNSRecordUpsertPayload(req.RecordType, req.NodeName, req.Content, req.TTL, req.State, req.Group, req.Host, req.Location), &resp); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
normalizeDNSRecord(&resp.DNSRecord)
|
normalizeDNSRecord(&resp.DNSRecord)
|
||||||
@@ -319,9 +322,10 @@ type dnsRecordUpsertPayload struct {
|
|||||||
State *bool `json:"state,omitempty"`
|
State *bool `json:"state,omitempty"`
|
||||||
Group string `json:"group,omitempty"`
|
Group string `json:"group,omitempty"`
|
||||||
Host string `json:"host,omitempty"`
|
Host string `json:"host,omitempty"`
|
||||||
|
Location string `json:"location,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildDNSRecordUpsertPayload(recordType string, nodeName string, content *string, ttl int64, state *bool, group string, host string) dnsRecordUpsertPayload {
|
func buildDNSRecordUpsertPayload(recordType string, nodeName string, content *string, ttl int64, state *bool, group string, host string, location string) dnsRecordUpsertPayload {
|
||||||
normalizedType := strings.ToUpper(strings.TrimSpace(recordType))
|
normalizedType := strings.ToUpper(strings.TrimSpace(recordType))
|
||||||
normalizedContent := normalizeOptionalContent(content)
|
normalizedContent := normalizeOptionalContent(content)
|
||||||
payload := dnsRecordUpsertPayload{
|
payload := dnsRecordUpsertPayload{
|
||||||
@@ -331,6 +335,7 @@ func buildDNSRecordUpsertPayload(recordType string, nodeName string, content *st
|
|||||||
State: state,
|
State: state,
|
||||||
Group: group,
|
Group: group,
|
||||||
Host: host,
|
Host: host,
|
||||||
|
Location: location,
|
||||||
}
|
}
|
||||||
|
|
||||||
switch normalizedType {
|
switch normalizedType {
|
||||||
@@ -346,8 +351,10 @@ func buildDNSRecordUpsertPayload(recordType string, nodeName string, content *st
|
|||||||
if normalizedContent != nil {
|
if normalizedContent != nil {
|
||||||
payload.Host = *normalizedContent
|
payload.Host = *normalizedContent
|
||||||
}
|
}
|
||||||
|
payload.Location = ""
|
||||||
default:
|
default:
|
||||||
payload.Content = normalizedContent
|
payload.Content = normalizedContent
|
||||||
|
payload.Location = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
|
|||||||
@@ -4,15 +4,18 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
|
"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
|
||||||
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
|
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
|
||||||
"github.com/hashicorp/terraform-plugin-framework/diag"
|
"github.com/hashicorp/terraform-plugin-framework/diag"
|
||||||
"github.com/hashicorp/terraform-plugin-framework/resource"
|
"github.com/hashicorp/terraform-plugin-framework/resource"
|
||||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
|
||||||
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
|
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
|
||||||
"github.com/hashicorp/terraform-plugin-framework/types"
|
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||||
|
|
||||||
@@ -37,9 +40,10 @@ type dnsRecordResourceModel struct {
|
|||||||
Content types.String `tfsdk:"content"`
|
Content types.String `tfsdk:"content"`
|
||||||
Dynamic types.Bool `tfsdk:"dynamic"`
|
Dynamic types.Bool `tfsdk:"dynamic"`
|
||||||
TTL types.Int64 `tfsdk:"ttl"`
|
TTL types.Int64 `tfsdk:"ttl"`
|
||||||
State types.Bool `tfsdk:"state"`
|
Enabled types.Bool `tfsdk:"enabled"`
|
||||||
Group types.String `tfsdk:"group"`
|
Group types.String `tfsdk:"group"`
|
||||||
Host types.String `tfsdk:"host"`
|
Host types.String `tfsdk:"host"`
|
||||||
|
Location types.String `tfsdk:"location"`
|
||||||
NodeName types.String `tfsdk:"node_name"`
|
NodeName types.String `tfsdk:"node_name"`
|
||||||
DomainID types.Int64 `tfsdk:"domain_id"`
|
DomainID types.Int64 `tfsdk:"domain_id"`
|
||||||
DomainName types.String `tfsdk:"domain_name"`
|
DomainName types.String `tfsdk:"domain_name"`
|
||||||
@@ -76,9 +80,10 @@ func (r *dnsRecordResource) Schema(_ context.Context, _ resource.SchemaRequest,
|
|||||||
Description: "DNS TTL in seconds.",
|
Description: "DNS TTL in seconds.",
|
||||||
Validators: []validator.Int64{int64validator.AtLeast(0)},
|
Validators: []validator.Int64{int64validator.AtLeast(0)},
|
||||||
},
|
},
|
||||||
"state": schema.BoolAttribute{Optional: true, Computed: true, Description: "Whether this DNS record is active."},
|
"enabled": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(true), Description: "Whether this DNS record is enabled/active."},
|
||||||
"group": schema.StringAttribute{Optional: true, Computed: true, Description: "Dynu group value for this record."},
|
"group": schema.StringAttribute{Optional: true, Computed: true, Description: "Dynu group value for this record."},
|
||||||
"host": schema.StringAttribute{Optional: true, Computed: true, Description: "Host field for supported Dynu record types."},
|
"host": schema.StringAttribute{Optional: true, Computed: true, Description: "Host field for supported Dynu record types."},
|
||||||
|
"location": schema.StringAttribute{Optional: true, Computed: true, Description: "Dynu location hint for A/AAAA records only."},
|
||||||
"node_name": schema.StringAttribute{Optional: true, Computed: true, Description: "Node/label portion of the record."},
|
"node_name": schema.StringAttribute{Optional: true, Computed: true, Description: "Node/label portion of the record."},
|
||||||
"domain_id": schema.Int64Attribute{Computed: true, Description: "Dynu domain ID resolved from hostname."},
|
"domain_id": schema.Int64Attribute{Computed: true, Description: "Dynu domain ID resolved from hostname."},
|
||||||
"domain_name": schema.StringAttribute{Computed: true, Description: "Dynu root domain name resolved from hostname."},
|
"domain_name": schema.StringAttribute{Computed: true, Description: "Dynu root domain name resolved from hostname."},
|
||||||
@@ -116,6 +121,8 @@ func (r *dnsRecordResource) ValidateConfig(ctx context.Context, req resource.Val
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
validateDNSRecordContentForTypeWithKnowledge(recordType, content, contentKnown, dynamicIntent, &resp.Diagnostics)
|
validateDNSRecordContentForTypeWithKnowledge(recordType, content, contentKnown, dynamicIntent, &resp.Diagnostics)
|
||||||
|
validateDNSRecordTTL(config.TTL, &resp.Diagnostics)
|
||||||
|
validateDNSRecordLocation(recordType, config.Location, &resp.Diagnostics)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *dnsRecordResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
|
func (r *dnsRecordResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
|
||||||
@@ -142,14 +149,21 @@ func (r *dnsRecordResource) Create(ctx context.Context, req resource.CreateReque
|
|||||||
RecordType: recordType,
|
RecordType: recordType,
|
||||||
Content: stringPointerFromOptionalContent(plan.Content),
|
Content: stringPointerFromOptionalContent(plan.Content),
|
||||||
TTL: int64FromOptional(plan.TTL),
|
TTL: int64FromOptional(plan.TTL),
|
||||||
State: boolPointerFromOptional(plan.State),
|
State: boolPointerFromOptional(plan.Enabled),
|
||||||
Group: stringFromOptional(plan.Group),
|
Group: stringFromOptional(plan.Group),
|
||||||
Host: stringFromOptional(plan.Host),
|
Host: stringFromOptional(plan.Host),
|
||||||
|
Location: stringFromOptional(plan.Location),
|
||||||
}
|
}
|
||||||
createReq = normalizeDNSRecordCreateRequestForType(createReq)
|
createReq = normalizeDNSRecordCreateRequestForType(createReq)
|
||||||
if !validateDNSRecordContentForType(createReq.RecordType, createReq.Content, dynamicIntent, &resp.Diagnostics) {
|
if !validateDNSRecordContentForType(createReq.RecordType, createReq.Content, dynamicIntent, &resp.Diagnostics) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !validateRecordTTLSeconds(createReq.TTL, &resp.Diagnostics) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validateLocationForType(createReq.RecordType, createReq.Location, &resp.Diagnostics) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
record, err := r.clientProvider.client.CreateDNSRecord(ctx, domainID, createReq)
|
record, err := r.clientProvider.client.CreateDNSRecord(ctx, domainID, createReq)
|
||||||
if err != nil && dynamicIntent && isUnsupportedEmptyContentError(err) {
|
if err != nil && dynamicIntent && isUnsupportedEmptyContentError(err) {
|
||||||
@@ -239,14 +253,21 @@ func (r *dnsRecordResource) Update(ctx context.Context, req resource.UpdateReque
|
|||||||
RecordType: recordType,
|
RecordType: recordType,
|
||||||
Content: stringPointerFromOptionalContent(plan.Content),
|
Content: stringPointerFromOptionalContent(plan.Content),
|
||||||
TTL: int64FromOptional(preferKnownInt64(plan.TTL, state.TTL)),
|
TTL: int64FromOptional(preferKnownInt64(plan.TTL, state.TTL)),
|
||||||
State: boolPointerFromOptional(preferKnownBool(plan.State, state.State)),
|
State: boolPointerFromOptional(preferKnownBool(plan.Enabled, state.Enabled)),
|
||||||
Group: stringFromOptional(preferKnownString(plan.Group, state.Group)),
|
Group: stringFromOptional(preferKnownString(plan.Group, state.Group)),
|
||||||
Host: stringFromOptional(preferKnownString(plan.Host, state.Host)),
|
Host: stringFromOptional(preferKnownString(plan.Host, state.Host)),
|
||||||
|
Location: locationForUpdate(recordType, plan.Location, state.Location),
|
||||||
}
|
}
|
||||||
updateReq = normalizeDNSRecordUpdateRequestForType(updateReq)
|
updateReq = normalizeDNSRecordUpdateRequestForType(updateReq)
|
||||||
if !validateDNSRecordContentForType(updateReq.RecordType, updateReq.Content, dynamicIntent, &resp.Diagnostics) {
|
if !validateDNSRecordContentForType(updateReq.RecordType, updateReq.Content, dynamicIntent, &resp.Diagnostics) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !validateRecordTTLSeconds(updateReq.TTL, &resp.Diagnostics) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validateLocationForType(updateReq.RecordType, updateReq.Location, &resp.Diagnostics) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := r.clientProvider.client.UpdateDNSRecord(ctx, domainID, recordID, updateReq); err != nil {
|
if _, err := r.clientProvider.client.UpdateDNSRecord(ctx, domainID, recordID, updateReq); err != nil {
|
||||||
if dynamicIntent && isUnsupportedEmptyContentError(err) {
|
if dynamicIntent && isUnsupportedEmptyContentError(err) {
|
||||||
@@ -258,6 +279,7 @@ func (r *dnsRecordResource) Update(ctx context.Context, req resource.UpdateReque
|
|||||||
State: updateReq.State,
|
State: updateReq.State,
|
||||||
Group: updateReq.Group,
|
Group: updateReq.Group,
|
||||||
Host: updateReq.Host,
|
Host: updateReq.Host,
|
||||||
|
Location: updateReq.Location,
|
||||||
}, &resp.Diagnostics); retryOK {
|
}, &resp.Diagnostics); retryOK {
|
||||||
updateReq.Content = retryReq.Content
|
updateReq.Content = retryReq.Content
|
||||||
updateReq.Group = retryReq.Group
|
updateReq.Group = retryReq.Group
|
||||||
@@ -324,9 +346,10 @@ func mapDNSRecordToState(record dynuclient.DNSRecord, dynamicIntent bool) dnsRec
|
|||||||
Content: content,
|
Content: content,
|
||||||
Dynamic: types.BoolValue(dynamicIntent),
|
Dynamic: types.BoolValue(dynamicIntent),
|
||||||
TTL: types.Int64Value(record.TTL),
|
TTL: types.Int64Value(record.TTL),
|
||||||
State: types.BoolValue(record.State),
|
Enabled: types.BoolValue(record.State),
|
||||||
Group: mapString(record.Group),
|
Group: mapString(record.Group),
|
||||||
Host: mapString(record.Host),
|
Host: mapString(record.Host),
|
||||||
|
Location: mapString(record.Location),
|
||||||
NodeName: mapString(record.NodeName),
|
NodeName: mapString(record.NodeName),
|
||||||
DomainID: types.Int64Value(record.DomainID),
|
DomainID: types.Int64Value(record.DomainID),
|
||||||
DomainName: mapString(record.DomainName),
|
DomainName: mapString(record.DomainName),
|
||||||
@@ -511,6 +534,16 @@ func validateDNSRecordContentForTypeWithKnowledge(recordType string, content *st
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if normalizedType == "CNAME" {
|
||||||
|
if net.ParseIP(trimmedContent) != nil {
|
||||||
|
diagnostics.AddError("Invalid DNS record content", fmt.Sprintf("Record type %q requires a hostname target, got IP %q.", normalizedType, trimmedContent))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(strings.ToLower(trimmedContent), "http://") || strings.HasPrefix(strings.ToLower(trimmedContent), "https://") {
|
||||||
|
diagnostics.AddError("Invalid DNS record content", fmt.Sprintf("Record type %q target must not include a URL scheme: %q.", normalizedType, trimmedContent))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if dynamicIntent {
|
if dynamicIntent {
|
||||||
diagnostics.AddError(
|
diagnostics.AddError(
|
||||||
@@ -535,6 +568,48 @@ func validateDNSRecordContentForTypeWithKnowledge(recordType string, content *st
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateDNSRecordTTL(ttl types.Int64, diagnostics *diag.Diagnostics) {
|
||||||
|
if ttl.IsNull() || ttl.IsUnknown() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
validateRecordTTLSeconds(ttl.ValueInt64(), diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRecordTTLSeconds(ttl int64, diagnostics *diag.Diagnostics) bool {
|
||||||
|
if ttl > 0 && ttl < int64((90*time.Second).Seconds()) {
|
||||||
|
diagnostics.AddError("Invalid TTL", fmt.Sprintf("invalid TTL %ds: Dynu requires TTL >= 90s", ttl))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDNSRecordLocation(recordType string, location types.String, diagnostics *diag.Diagnostics) {
|
||||||
|
if location.IsNull() || location.IsUnknown() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
validateLocationForType(recordType, location.ValueString(), diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateLocationForType(recordType string, location string, diagnostics *diag.Diagnostics) bool {
|
||||||
|
if strings.TrimSpace(location) == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
normalizedType := strings.ToUpper(strings.TrimSpace(recordType))
|
||||||
|
if normalizedType != "A" && normalizedType != "AAAA" {
|
||||||
|
diagnostics.AddError("Invalid location", fmt.Sprintf("location is only supported for A and AAAA records, got %q.", normalizedType))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationForUpdate(recordType string, planLocation types.String, stateLocation types.String) string {
|
||||||
|
normalizedType := strings.ToUpper(strings.TrimSpace(recordType))
|
||||||
|
if normalizedType != "A" && normalizedType != "AAAA" {
|
||||||
|
return stringFromOptional(planLocation)
|
||||||
|
}
|
||||||
|
return stringFromOptional(preferKnownString(planLocation, stateLocation))
|
||||||
|
}
|
||||||
|
|
||||||
func resolveDynamicIntent(recordType string, content types.String, dynamic types.Bool, diagnostics *diag.Diagnostics) (bool, bool) {
|
func resolveDynamicIntent(recordType string, content types.String, dynamic types.Bool, diagnostics *diag.Diagnostics) (bool, bool) {
|
||||||
normalizedType := strings.ToUpper(strings.TrimSpace(recordType))
|
normalizedType := strings.ToUpper(strings.TrimSpace(recordType))
|
||||||
contentPtr := stringPointerFromOptionalContent(content)
|
contentPtr := stringPointerFromOptionalContent(content)
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ func TestIntegrationResourceDNSRecordLifecycleAndImport(t *testing.T) {
|
|||||||
Hostname: types.StringValue("api.a.example.com"),
|
Hostname: types.StringValue("api.a.example.com"),
|
||||||
RecordType: types.StringValue("TXT"),
|
RecordType: types.StringValue("TXT"),
|
||||||
Content: types.StringValue("created"),
|
Content: types.StringValue("created"),
|
||||||
TTL: types.Int64Value(60),
|
TTL: types.Int64Value(90),
|
||||||
State: types.BoolValue(true),
|
Enabled: types.BoolValue(true),
|
||||||
Group: types.StringValue("test"),
|
Group: types.StringValue("test"),
|
||||||
Host: types.StringNull(),
|
Host: types.StringNull(),
|
||||||
NodeName: types.StringNull(),
|
NodeName: types.StringNull(),
|
||||||
@@ -157,8 +157,8 @@ func TestIntegrationResourceDNSRecordDynamicAStateStableAndTransitionToStatic(t
|
|||||||
Hostname: types.StringValue("api.a.example.com"),
|
Hostname: types.StringValue("api.a.example.com"),
|
||||||
RecordType: types.StringValue("A"),
|
RecordType: types.StringValue("A"),
|
||||||
Content: types.StringNull(),
|
Content: types.StringNull(),
|
||||||
TTL: types.Int64Value(60),
|
TTL: types.Int64Value(90),
|
||||||
State: types.BoolValue(true),
|
Enabled: types.BoolValue(true),
|
||||||
}
|
}
|
||||||
plan := tfsdk.Plan{Schema: schemaResp.Schema}
|
plan := tfsdk.Plan{Schema: schemaResp.Schema}
|
||||||
if diags := plan.Set(ctx, &createPlan); diags.HasError() {
|
if diags := plan.Set(ctx, &createPlan); diags.HasError() {
|
||||||
@@ -234,8 +234,8 @@ func TestIntegrationResourceDNSRecordUpdateUsesStateIDWhenPlanIDUnknown(t *testi
|
|||||||
Hostname: types.StringValue("api.a.example.com"),
|
Hostname: types.StringValue("api.a.example.com"),
|
||||||
RecordType: types.StringValue("TXT"),
|
RecordType: types.StringValue("TXT"),
|
||||||
Content: types.StringValue("v=one"),
|
Content: types.StringValue("v=one"),
|
||||||
TTL: types.Int64Value(60),
|
TTL: types.Int64Value(90),
|
||||||
State: types.BoolValue(true),
|
Enabled: types.BoolValue(true),
|
||||||
Group: types.StringValue("test-group"),
|
Group: types.StringValue("test-group"),
|
||||||
Host: types.StringValue("test-host"),
|
Host: types.StringValue("test-host"),
|
||||||
NodeName: types.StringValue("test-node"),
|
NodeName: types.StringValue("test-node"),
|
||||||
|
|||||||
@@ -173,6 +173,48 @@ func TestNormalizeDNSRecordUpdateRequestForType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateRecordTTLSeconds(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
ttl int64
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{ttl: 0, want: true},
|
||||||
|
{ttl: 89, want: false},
|
||||||
|
{ttl: 90, want: true},
|
||||||
|
{ttl: 1800, want: true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
diags := diag.Diagnostics{}
|
||||||
|
got := validateRecordTTLSeconds(tc.ttl, &diags)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("validateRecordTTLSeconds(%d)=%v, want %v", tc.ttl, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLocationForType(t *testing.T) {
|
||||||
|
diags := diag.Diagnostics{}
|
||||||
|
if !validateLocationForType("A", "us", &diags) || diags.HasError() {
|
||||||
|
t.Fatal("expected location for A to be valid")
|
||||||
|
}
|
||||||
|
diags = diag.Diagnostics{}
|
||||||
|
if validateLocationForType("CNAME", "us", &diags) || !diags.HasError() {
|
||||||
|
t.Fatal("expected location for CNAME to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocationForUpdate(t *testing.T) {
|
||||||
|
if got := locationForUpdate("A", types.StringNull(), types.StringValue("us")); got != "us" {
|
||||||
|
t.Fatalf("expected A record update to preserve state location, got %q", got)
|
||||||
|
}
|
||||||
|
if got := locationForUpdate("CNAME", types.StringNull(), types.StringValue("us")); got != "" {
|
||||||
|
t.Fatalf("expected non-A/AAAA type to ignore prior state location when omitted, got %q", got)
|
||||||
|
}
|
||||||
|
if got := locationForUpdate("TXT", types.StringValue("eu"), types.StringValue("us")); got != "eu" {
|
||||||
|
t.Fatalf("expected explicit plan location to be returned as-is, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInferDynamicIntentFromState(t *testing.T) {
|
func TestInferDynamicIntentFromState(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
Reference in New Issue
Block a user