Archived
Merge pull request #48 from beatz174-bit/codex/add-dynu_domain-resource-and-crud-functions
Add dynu_domain resource and extended DNS record support (MX/SRV/CAA)
This commit is contained in:
@@ -327,8 +327,9 @@ Implemented:
|
||||
- Provider authentication via `api_key` or `DYNU_API_KEY`
|
||||
- Optional provider `base_url` override
|
||||
- Data sources: `dynu_domains`, `dynu_domain`, `dynu_dns_records`
|
||||
- Resource: `dynu_domain` (CRUD + import using numeric domain ID)
|
||||
- Resource: `dynu_dns_record` (CRUD + import using `domain_id/record_id`)
|
||||
|
||||
Not implemented yet:
|
||||
- Additional Terraform resources beyond `dynu_dns_record`
|
||||
- Additional Terraform resources beyond `dynu_domain` and `dynu_dns_record`
|
||||
- Broader Dynu API coverage outside current DNS/domain scope
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# dynu_domain Resource
|
||||
|
||||
Manages a Dynu DNS domain.
|
||||
|
||||
## Example Usage
|
||||
|
||||
```terraform
|
||||
resource "dynu_domain" "example" {
|
||||
name = "my-test-domain.example"
|
||||
ipv4_address = "203.0.113.10"
|
||||
ttl = 300
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "www" {
|
||||
hostname = "www.${dynu_domain.example.name}"
|
||||
record_type = "A"
|
||||
content = "203.0.113.20"
|
||||
}
|
||||
```
|
||||
|
||||
## Import
|
||||
|
||||
Import using the numeric Dynu domain ID:
|
||||
|
||||
```bash
|
||||
terraform import dynu_domain.example 1234
|
||||
```
|
||||
|
||||
## Attributes
|
||||
|
||||
- `id` (Number) Dynu domain ID.
|
||||
- `name` (String) Domain name (forces replacement when changed).
|
||||
- `ipv4_address` (String) Optional IPv4 address.
|
||||
- `ipv6_address` (String) Optional IPv6 address.
|
||||
- `ttl` (Number) Optional TTL.
|
||||
- `group` (String) Optional group.
|
||||
- `state` (String) Computed state from Dynu API.
|
||||
- `token` (String, Sensitive) Computed domain token from Dynu API.
|
||||
|
||||
## DNS record examples under this domain
|
||||
|
||||
```terraform
|
||||
resource "dynu_dns_record" "mx" {
|
||||
hostname = dynu_domain.example.name
|
||||
record_type = "MX"
|
||||
content = "mail.my-test-domain.example"
|
||||
priority = 10
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "txt_spf" {
|
||||
hostname = dynu_domain.example.name
|
||||
record_type = "TXT"
|
||||
content = "v=spf1 include:_spf.example.com ~all"
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "cname" {
|
||||
hostname = "app.${dynu_domain.example.name}"
|
||||
record_type = "CNAME"
|
||||
content = "target.example.net"
|
||||
}
|
||||
|
||||
resource "dynu_dns_record" "srv" {
|
||||
hostname = "_sip._tcp.${dynu_domain.example.name}"
|
||||
record_type = "SRV"
|
||||
content = "sip.my-test-domain.example"
|
||||
priority = 10
|
||||
weight = 5
|
||||
port = 5060
|
||||
}
|
||||
```
|
||||
@@ -106,6 +106,12 @@ type DNSRecord struct {
|
||||
UpdatedOn string `json:"updatedOn"`
|
||||
Group string `json:"group"`
|
||||
Host string `json:"host"`
|
||||
Priority int64 `json:"priority"`
|
||||
Weight int64 `json:"weight"`
|
||||
Port int64 `json:"port"`
|
||||
Flags int64 `json:"flags"`
|
||||
Tag string `json:"tag"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type CreateDNSRecordRequest struct {
|
||||
@@ -116,6 +122,12 @@ type CreateDNSRecordRequest struct {
|
||||
State *bool `json:"state,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Priority int64 `json:"priority,omitempty"`
|
||||
Weight int64 `json:"weight,omitempty"`
|
||||
Port int64 `json:"port,omitempty"`
|
||||
Flags int64 `json:"flags,omitempty"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateDNSRecordRequest struct {
|
||||
@@ -126,8 +138,24 @@ type UpdateDNSRecordRequest struct {
|
||||
State *bool `json:"state,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Priority int64 `json:"priority,omitempty"`
|
||||
Weight int64 `json:"weight,omitempty"`
|
||||
Port int64 `json:"port,omitempty"`
|
||||
Flags int64 `json:"flags,omitempty"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
type CreateDomainRequest struct {
|
||||
Name string `json:"name"`
|
||||
IPv4Address string `json:"ipv4Address,omitempty"`
|
||||
IPv6Address string `json:"ipv6Address,omitempty"`
|
||||
TTL int64 `json:"ttl,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateDomainRequest = CreateDomainRequest
|
||||
|
||||
type listDomainsResponse struct {
|
||||
apiResponse
|
||||
Domains []Domain `json:"domains"`
|
||||
@@ -172,6 +200,26 @@ func (c *Client) GetDomainByID(ctx context.Context, domainID int64) (*Domain, er
|
||||
return &resp.Domain, nil
|
||||
}
|
||||
|
||||
func (c *Client) CreateDomain(ctx context.Context, req CreateDomainRequest) (*Domain, error) {
|
||||
var resp getDomainResponse
|
||||
if err := c.doRequest(ctx, http.MethodPost, "/dns", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp.Domain, nil
|
||||
}
|
||||
|
||||
func (c *Client) UpdateDomain(ctx context.Context, domainID int64, req UpdateDomainRequest) (*Domain, error) {
|
||||
var resp getDomainResponse
|
||||
if err := c.doRequest(ctx, http.MethodPost, fmt.Sprintf("/dns/%d", domainID), req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp.Domain, nil
|
||||
}
|
||||
|
||||
func (c *Client) DeleteDomain(ctx context.Context, domainID int64) error {
|
||||
return c.doRequest(ctx, http.MethodDelete, fmt.Sprintf("/dns/%d", domainID), nil, nil)
|
||||
}
|
||||
|
||||
func (c *Client) GetRootDomain(ctx context.Context, hostname string) (int64, string, error) {
|
||||
var resp getRootResponse
|
||||
if err := c.doRequest(ctx, http.MethodGet, fmt.Sprintf("/dns/getroot/%s", url.PathEscape(hostname)), nil, &resp); err != nil {
|
||||
@@ -207,7 +255,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) {
|
||||
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), &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalizeDNSRecord(&resp.DNSRecord)
|
||||
@@ -216,7 +264,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) {
|
||||
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(CreateDNSRecordRequest(req)), &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalizeDNSRecord(&resp.DNSRecord)
|
||||
@@ -319,18 +367,30 @@ type dnsRecordUpsertPayload struct {
|
||||
State *bool `json:"state,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Priority int64 `json:"priority,omitempty"`
|
||||
Weight int64 `json:"weight,omitempty"`
|
||||
Port int64 `json:"port,omitempty"`
|
||||
Flags int64 `json:"flags,omitempty"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
func buildDNSRecordUpsertPayload(recordType string, nodeName string, content *string, ttl int64, state *bool, group string, host string) dnsRecordUpsertPayload {
|
||||
normalizedType := strings.ToUpper(strings.TrimSpace(recordType))
|
||||
normalizedContent := normalizeOptionalContent(content)
|
||||
func buildDNSRecordUpsertPayload(req CreateDNSRecordRequest) dnsRecordUpsertPayload {
|
||||
normalizedType := strings.ToUpper(strings.TrimSpace(req.RecordType))
|
||||
normalizedContent := normalizeOptionalContent(req.Content)
|
||||
payload := dnsRecordUpsertPayload{
|
||||
NodeName: nodeName,
|
||||
NodeName: req.NodeName,
|
||||
RecordType: normalizedType,
|
||||
TTL: ttl,
|
||||
State: state,
|
||||
Group: group,
|
||||
Host: host,
|
||||
TTL: req.TTL,
|
||||
State: req.State,
|
||||
Group: req.Group,
|
||||
Host: req.Host,
|
||||
Priority: req.Priority,
|
||||
Weight: req.Weight,
|
||||
Port: req.Port,
|
||||
Flags: req.Flags,
|
||||
Tag: req.Tag,
|
||||
Value: req.Value,
|
||||
}
|
||||
|
||||
switch normalizedType {
|
||||
@@ -346,6 +406,14 @@ func buildDNSRecordUpsertPayload(recordType string, nodeName string, content *st
|
||||
if normalizedContent != nil {
|
||||
payload.Host = *normalizedContent
|
||||
}
|
||||
case "MX", "SRV", "NS", "PTR":
|
||||
if normalizedContent != nil {
|
||||
payload.Host = *normalizedContent
|
||||
}
|
||||
case "CAA":
|
||||
if normalizedContent != nil && payload.Value == "" {
|
||||
payload.Value = *normalizedContent
|
||||
}
|
||||
default:
|
||||
payload.Content = normalizedContent
|
||||
}
|
||||
|
||||
@@ -457,3 +457,91 @@ func TestClientCreateDNSRecordOmitsContentWhenUnset(t *testing.T) {
|
||||
func stringPointer(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
func TestClientDomainCRUD(t *testing.T) {
|
||||
fake := fakedynu.NewServer()
|
||||
defer fake.Close()
|
||||
client := dynuclient.New("test-key", dynuclient.WithBaseURL(fake.BaseURL()), dynuclient.WithHTTPClient(fake.Client()))
|
||||
|
||||
created, err := client.CreateDomain(context.Background(), dynuclient.CreateDomainRequest{Name: "new.example.com", TTL: 300, IPv4Address: "203.0.113.10"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDomain() error = %v", err)
|
||||
}
|
||||
if created.ID == 0 || created.Name != "new.example.com" {
|
||||
t.Fatalf("unexpected created domain: %#v", created)
|
||||
}
|
||||
|
||||
updated, err := client.UpdateDomain(context.Background(), created.ID, dynuclient.UpdateDomainRequest{Name: "new.example.com", TTL: 600})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateDomain() error = %v", err)
|
||||
}
|
||||
if updated.TTL != 600 {
|
||||
t.Fatalf("expected ttl 600, got %d", updated.TTL)
|
||||
}
|
||||
|
||||
if err := client.DeleteDomain(context.Background(), created.ID); err != nil {
|
||||
t.Fatalf("DeleteDomain() error = %v", err)
|
||||
}
|
||||
_, err = client.GetDomainByID(context.Background(), created.ID)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error reading deleted domain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientCreateDNSRecordSendsMXFields(t *testing.T) {
|
||||
fake := fakedynu.NewServer()
|
||||
defer fake.Close()
|
||||
client := dynuclient.New("test-key", dynuclient.WithBaseURL(fake.BaseURL()), dynuclient.WithHTTPClient(fake.Client()))
|
||||
host := "mail.example.com"
|
||||
_, err := client.CreateDNSRecord(context.Background(), 1001, dynuclient.CreateDNSRecordRequest{NodeName: "@", RecordType: "MX", Content: &host, Priority: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDNSRecord() error = %v", err)
|
||||
}
|
||||
rec, _ := client.GetDNSRecord(context.Background(), 1001, 21)
|
||||
if rec.Priority != 10 || rec.Host != host {
|
||||
t.Fatalf("unexpected MX fields: %#v", rec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientCreateDNSRecordSendsSRVFields(t *testing.T) {
|
||||
fake := fakedynu.NewServer()
|
||||
defer fake.Close()
|
||||
client := dynuclient.New("test-key", dynuclient.WithBaseURL(fake.BaseURL()), dynuclient.WithHTTPClient(fake.Client()))
|
||||
target := "sip.example.com"
|
||||
_, err := client.CreateDNSRecord(context.Background(), 1001, dynuclient.CreateDNSRecordRequest{NodeName: "_sip._tcp", RecordType: "SRV", Content: &target, Priority: 10, Weight: 5, Port: 5060})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDNSRecord() error = %v", err)
|
||||
}
|
||||
rec, _ := client.GetDNSRecord(context.Background(), 1001, 21)
|
||||
if rec.Port != 5060 || rec.Weight != 5 || rec.Host != target {
|
||||
t.Fatalf("unexpected SRV fields: %#v", rec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientCreateDNSRecordPreservesTXTContent(t *testing.T) {
|
||||
fake := fakedynu.NewServer()
|
||||
defer fake.Close()
|
||||
client := dynuclient.New("test-key", dynuclient.WithBaseURL(fake.BaseURL()), dynuclient.WithHTTPClient(fake.Client()))
|
||||
txt := "v=spf1 include:_spf.example.com ~all"
|
||||
rec, err := client.CreateDNSRecord(context.Background(), 1001, dynuclient.CreateDNSRecordRequest{NodeName: "@", RecordType: "TXT", Content: &txt})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDNSRecord() error = %v", err)
|
||||
}
|
||||
if rec.Content != txt {
|
||||
t.Fatalf("expected TXT content preserved, got %q", rec.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientCreateDNSRecordSendsCAAFields(t *testing.T) {
|
||||
fake := fakedynu.NewServer()
|
||||
defer fake.Close()
|
||||
client := dynuclient.New("test-key", dynuclient.WithBaseURL(fake.BaseURL()), dynuclient.WithHTTPClient(fake.Client()))
|
||||
value := "letsencrypt.org"
|
||||
rec, err := client.CreateDNSRecord(context.Background(), 1001, dynuclient.CreateDNSRecordRequest{NodeName: "@", RecordType: "CAA", Content: &value, Flags: 0, Tag: "issue"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDNSRecord() error = %v", err)
|
||||
}
|
||||
if rec.Tag != "issue" || rec.Value != value {
|
||||
t.Fatalf("unexpected CAA fields: %#v", rec)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ func (p *dynuProvider) DataSources(_ context.Context) []func() datasource.DataSo
|
||||
|
||||
func (p *dynuProvider) Resources(_ context.Context) []func() resource.Resource {
|
||||
return []func() resource.Resource{
|
||||
NewDomainResource,
|
||||
NewDNSRecordResource,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,12 @@ type dnsRecordResourceModel struct {
|
||||
Enabled types.Bool `tfsdk:"enabled"`
|
||||
Group types.String `tfsdk:"group"`
|
||||
Host types.String `tfsdk:"host"`
|
||||
Priority types.Int64 `tfsdk:"priority"`
|
||||
Weight types.Int64 `tfsdk:"weight"`
|
||||
Port types.Int64 `tfsdk:"port"`
|
||||
Flags types.Int64 `tfsdk:"flags"`
|
||||
Tag types.String `tfsdk:"tag"`
|
||||
Value types.String `tfsdk:"value"`
|
||||
NodeName types.String `tfsdk:"node_name"`
|
||||
DomainID types.Int64 `tfsdk:"domain_id"`
|
||||
DomainName types.String `tfsdk:"domain_name"`
|
||||
@@ -82,6 +88,12 @@ func (r *dnsRecordResource) Schema(_ context.Context, _ resource.SchemaRequest,
|
||||
"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."},
|
||||
"host": schema.StringAttribute{Optional: true, Computed: true, Description: "Host field for supported Dynu record types."},
|
||||
"priority": schema.Int64Attribute{Optional: true, Computed: true, Description: "Priority value used by MX/SRV records."},
|
||||
"weight": schema.Int64Attribute{Optional: true, Computed: true, Description: "Weight value used by SRV records."},
|
||||
"port": schema.Int64Attribute{Optional: true, Computed: true, Description: "Port value used by SRV records."},
|
||||
"flags": schema.Int64Attribute{Optional: true, Computed: true, Description: "Flags value used by CAA records."},
|
||||
"tag": schema.StringAttribute{Optional: true, Computed: true, Description: "Tag value used by CAA records."},
|
||||
"value": schema.StringAttribute{Optional: true, Computed: true, Description: "Value field used by CAA records."},
|
||||
"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_name": schema.StringAttribute{Computed: true, Description: "Dynu root domain name resolved from hostname."},
|
||||
@@ -149,6 +161,12 @@ func (r *dnsRecordResource) Create(ctx context.Context, req resource.CreateReque
|
||||
State: boolPointerFromOptional(plan.Enabled),
|
||||
Group: stringFromOptional(plan.Group),
|
||||
Host: stringFromOptional(plan.Host),
|
||||
Priority: int64FromOptional(plan.Priority),
|
||||
Weight: int64FromOptional(plan.Weight),
|
||||
Port: int64FromOptional(plan.Port),
|
||||
Flags: int64FromOptional(plan.Flags),
|
||||
Tag: stringFromOptional(plan.Tag),
|
||||
Value: stringFromOptional(plan.Value),
|
||||
}
|
||||
createReq = normalizeDNSRecordCreateRequestForType(createReq)
|
||||
if !validateDNSRecordContentForType(createReq.RecordType, createReq.Content, dynamicIntent, &resp.Diagnostics) {
|
||||
@@ -249,6 +267,12 @@ func (r *dnsRecordResource) Update(ctx context.Context, req resource.UpdateReque
|
||||
State: boolPointerFromOptional(preferKnownBool(plan.Enabled, state.Enabled)),
|
||||
Group: stringFromOptional(preferKnownString(plan.Group, state.Group)),
|
||||
Host: stringFromOptional(preferKnownString(plan.Host, state.Host)),
|
||||
Priority: int64FromOptional(preferKnownInt64(plan.Priority, state.Priority)),
|
||||
Weight: int64FromOptional(preferKnownInt64(plan.Weight, state.Weight)),
|
||||
Port: int64FromOptional(preferKnownInt64(plan.Port, state.Port)),
|
||||
Flags: int64FromOptional(preferKnownInt64(plan.Flags, state.Flags)),
|
||||
Tag: stringFromOptional(preferKnownString(plan.Tag, state.Tag)),
|
||||
Value: stringFromOptional(preferKnownString(plan.Value, state.Value)),
|
||||
}
|
||||
updateReq = normalizeDNSRecordUpdateRequestForType(updateReq)
|
||||
if !validateDNSRecordContentForType(updateReq.RecordType, updateReq.Content, dynamicIntent, &resp.Diagnostics) {
|
||||
@@ -268,6 +292,12 @@ func (r *dnsRecordResource) Update(ctx context.Context, req resource.UpdateReque
|
||||
State: updateReq.State,
|
||||
Group: updateReq.Group,
|
||||
Host: updateReq.Host,
|
||||
Priority: updateReq.Priority,
|
||||
Weight: updateReq.Weight,
|
||||
Port: updateReq.Port,
|
||||
Flags: updateReq.Flags,
|
||||
Tag: updateReq.Tag,
|
||||
Value: updateReq.Value,
|
||||
}, &resp.Diagnostics); retryOK {
|
||||
updateReq.Content = retryReq.Content
|
||||
updateReq.Group = retryReq.Group
|
||||
@@ -337,6 +367,12 @@ func mapDNSRecordToState(record dynuclient.DNSRecord, dynamicIntent bool) dnsRec
|
||||
Enabled: types.BoolValue(record.State),
|
||||
Group: mapString(record.Group),
|
||||
Host: mapString(record.Host),
|
||||
Priority: types.Int64Value(record.Priority),
|
||||
Weight: types.Int64Value(record.Weight),
|
||||
Port: types.Int64Value(record.Port),
|
||||
Flags: types.Int64Value(record.Flags),
|
||||
Tag: mapString(record.Tag),
|
||||
Value: mapString(record.Value),
|
||||
NodeName: mapString(record.NodeName),
|
||||
DomainID: types.Int64Value(record.DomainID),
|
||||
DomainName: mapString(record.DomainName),
|
||||
@@ -455,19 +491,45 @@ func stringPointer(value string) *string {
|
||||
}
|
||||
|
||||
func normalizeDNSRecordCreateRequestForType(req dynuclient.CreateDNSRecordRequest) dynuclient.CreateDNSRecordRequest {
|
||||
if strings.EqualFold(strings.TrimSpace(req.RecordType), "CNAME") {
|
||||
switch strings.ToUpper(strings.TrimSpace(req.RecordType)) {
|
||||
case "CNAME", "NS", "PTR":
|
||||
if normalizedContent := normalizeOptionalContentString(req.Content); normalizedContent != nil {
|
||||
req.Host = *normalizedContent
|
||||
}
|
||||
case "MX":
|
||||
if normalizedContent := normalizeOptionalContentString(req.Content); normalizedContent != nil {
|
||||
req.Host = *normalizedContent
|
||||
}
|
||||
case "SRV":
|
||||
if normalizedContent := normalizeOptionalContentString(req.Content); normalizedContent != nil {
|
||||
req.Host = *normalizedContent
|
||||
}
|
||||
case "CAA":
|
||||
if normalizedContent := normalizeOptionalContentString(req.Content); normalizedContent != nil && req.Value == "" {
|
||||
req.Value = *normalizedContent
|
||||
}
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func normalizeDNSRecordUpdateRequestForType(req dynuclient.UpdateDNSRecordRequest) dynuclient.UpdateDNSRecordRequest {
|
||||
if strings.EqualFold(strings.TrimSpace(req.RecordType), "CNAME") {
|
||||
switch strings.ToUpper(strings.TrimSpace(req.RecordType)) {
|
||||
case "CNAME", "NS", "PTR":
|
||||
if normalizedContent := normalizeOptionalContentString(req.Content); normalizedContent != nil {
|
||||
req.Host = *normalizedContent
|
||||
}
|
||||
case "MX":
|
||||
if normalizedContent := normalizeOptionalContentString(req.Content); normalizedContent != nil {
|
||||
req.Host = *normalizedContent
|
||||
}
|
||||
case "SRV":
|
||||
if normalizedContent := normalizeOptionalContentString(req.Content); normalizedContent != nil {
|
||||
req.Host = *normalizedContent
|
||||
}
|
||||
case "CAA":
|
||||
if normalizedContent := normalizeOptionalContentString(req.Content); normalizedContent != nil && req.Value == "" {
|
||||
req.Value = *normalizedContent
|
||||
}
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
|
||||
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||
|
||||
"github.com/dynu/terraform-provider-dynu/internal/dynuclient"
|
||||
)
|
||||
|
||||
var (
|
||||
_ resource.Resource = &domainResource{}
|
||||
_ resource.ResourceWithConfigure = &domainResource{}
|
||||
_ resource.ResourceWithImportState = &domainResource{}
|
||||
)
|
||||
|
||||
type domainResource struct{ clientProvider *providerData }
|
||||
|
||||
type domainResourceModel struct {
|
||||
ID types.Int64 `tfsdk:"id"`
|
||||
Name types.String `tfsdk:"name"`
|
||||
IPv4Address types.String `tfsdk:"ipv4_address"`
|
||||
IPv6Address types.String `tfsdk:"ipv6_address"`
|
||||
TTL types.Int64 `tfsdk:"ttl"`
|
||||
Group types.String `tfsdk:"group"`
|
||||
State types.String `tfsdk:"state"`
|
||||
Token types.String `tfsdk:"token"`
|
||||
}
|
||||
|
||||
func NewDomainResource() resource.Resource { return &domainResource{} }
|
||||
func (r *domainResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
|
||||
resp.TypeName = req.ProviderTypeName + "_domain"
|
||||
}
|
||||
func (r *domainResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
|
||||
resp.Schema = schema.Schema{Description: "Manages a Dynu DNS domain.", Attributes: map[string]schema.Attribute{
|
||||
"id": schema.Int64Attribute{Computed: true},
|
||||
"name": schema.StringAttribute{Required: true, PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}},
|
||||
"ipv4_address": schema.StringAttribute{Optional: true, Computed: true},
|
||||
"ipv6_address": schema.StringAttribute{Optional: true, Computed: true},
|
||||
"ttl": schema.Int64Attribute{Optional: true, Computed: true},
|
||||
"group": schema.StringAttribute{Optional: true, Computed: true},
|
||||
"state": schema.StringAttribute{Computed: true},
|
||||
"token": schema.StringAttribute{Computed: true, Sensitive: true},
|
||||
}}
|
||||
}
|
||||
func (r *domainResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
|
||||
if req.ProviderData == nil {
|
||||
return
|
||||
}
|
||||
providerData, ok := req.ProviderData.(*providerData)
|
||||
if !ok {
|
||||
resp.Diagnostics.AddError("Unexpected resource configure type", fmt.Sprintf("Expected *providerData, got %T", req.ProviderData))
|
||||
return
|
||||
}
|
||||
r.clientProvider = providerData
|
||||
}
|
||||
func (r *domainResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
|
||||
var plan domainResourceModel
|
||||
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
domain, err := r.clientProvider.client.CreateDomain(ctx, dynuclient.CreateDomainRequest{Name: plan.Name.ValueString(), IPv4Address: stringFromOptional(plan.IPv4Address), IPv6Address: stringFromOptional(plan.IPv6Address), TTL: int64FromOptional(plan.TTL), Group: stringFromOptional(plan.Group)})
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError(diagnosticSummary("Unable to create Dynu domain", err), err.Error())
|
||||
return
|
||||
}
|
||||
state := mapDomainResource(*domain)
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
|
||||
}
|
||||
func (r *domainResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
|
||||
var state domainResourceModel
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
domain, err := r.clientProvider.client.GetDomainByID(ctx, state.ID.ValueInt64())
|
||||
if err != nil {
|
||||
var apiErr *dynuclient.APIError
|
||||
if errors.As(err, &apiErr) && apiErr.StatusCode == 404 {
|
||||
resp.State.RemoveResource(ctx)
|
||||
return
|
||||
}
|
||||
resp.Diagnostics.AddError(diagnosticSummary("Unable to read Dynu domain", err), err.Error())
|
||||
return
|
||||
}
|
||||
next := mapDomainResource(*domain)
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &next)...)
|
||||
}
|
||||
func (r *domainResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
|
||||
var plan domainResourceModel
|
||||
var state domainResourceModel
|
||||
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
domain, err := r.clientProvider.client.UpdateDomain(ctx, state.ID.ValueInt64(), dynuclient.UpdateDomainRequest{Name: plan.Name.ValueString(), IPv4Address: stringFromOptional(plan.IPv4Address), IPv6Address: stringFromOptional(plan.IPv6Address), TTL: int64FromOptional(plan.TTL), Group: stringFromOptional(plan.Group)})
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError(diagnosticSummary("Unable to update Dynu domain", err), err.Error())
|
||||
return
|
||||
}
|
||||
next := mapDomainResource(*domain)
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &next)...)
|
||||
}
|
||||
func (r *domainResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
|
||||
var state domainResourceModel
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
err := r.clientProvider.client.DeleteDomain(ctx, state.ID.ValueInt64())
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
var apiErr *dynuclient.APIError
|
||||
if errors.As(err, &apiErr) && apiErr.StatusCode == 404 {
|
||||
return
|
||||
}
|
||||
resp.Diagnostics.AddError(diagnosticSummary("Unable to delete Dynu domain", err), err.Error())
|
||||
}
|
||||
func (r *domainResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(req.ID), 10, 64)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Invalid domain import ID", "Use the Dynu numeric domain ID.")
|
||||
return
|
||||
}
|
||||
state := domainResourceModel{ID: types.Int64Value(id)}
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
|
||||
}
|
||||
|
||||
func mapDomainResource(domain dynuclient.Domain) domainResourceModel {
|
||||
return domainResourceModel{ID: types.Int64Value(domain.ID), Name: types.StringValue(strings.TrimSpace(domain.Name)), IPv4Address: mapString(domain.IPv4Address), IPv6Address: mapString(domain.IPv6Address), TTL: types.Int64Value(domain.TTL), Group: mapString(domain.Group), State: mapString(domain.State), Token: mapString(domain.Token)}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/dynu/terraform-provider-dynu/internal/testutil/fakedynu"
|
||||
"github.com/hashicorp/terraform-plugin-framework/path"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource"
|
||||
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
|
||||
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||
)
|
||||
|
||||
func TestIntegrationResourceDomainLifecycleAndImport(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fake := fakedynu.NewServer()
|
||||
defer fake.Close()
|
||||
r := NewDomainResource().(*domainResource)
|
||||
configureResource(t, r, fake.BaseURL())
|
||||
var schemaResp resource.SchemaResponse
|
||||
r.Schema(ctx, resource.SchemaRequest{}, &schemaResp)
|
||||
|
||||
planModel := domainResourceModel{Name: types.StringValue("new.example.com"), TTL: types.Int64Value(120), IPv4Address: types.StringValue("203.0.113.7")}
|
||||
plan := tfsdk.Plan{Schema: schemaResp.Schema}
|
||||
_ = plan.Set(ctx, &planModel)
|
||||
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 domainResourceModel
|
||||
_ = createResp.State.Get(ctx, &state)
|
||||
if state.ID.ValueInt64() == 0 {
|
||||
t.Fatal("expected created id")
|
||||
}
|
||||
|
||||
state.TTL = types.Int64Value(300)
|
||||
plan = tfsdk.Plan{Schema: schemaResp.Schema}
|
||||
_ = plan.Set(ctx, &state)
|
||||
updateResp := resource.UpdateResponse{State: tfsdk.State{Schema: schemaResp.Schema}}
|
||||
r.Update(ctx, resource.UpdateRequest{Plan: plan, State: createResp.State}, &updateResp)
|
||||
if updateResp.Diagnostics.HasError() {
|
||||
t.Fatalf("update diagnostics: %v", updateResp.Diagnostics)
|
||||
}
|
||||
|
||||
importResp := resource.ImportStateResponse{State: tfsdk.State{Schema: schemaResp.Schema}}
|
||||
r.ImportState(ctx, resource.ImportStateRequest{ID: "5002"}, &importResp)
|
||||
if importResp.Diagnostics.HasError() {
|
||||
t.Fatalf("import diagnostics: %v", importResp.Diagnostics)
|
||||
}
|
||||
var imported int64
|
||||
_ = importResp.State.GetAttribute(ctx, path.Root("id"), &imported)
|
||||
if imported != 5002 {
|
||||
t.Fatalf("unexpected imported id %d", imported)
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,19 @@ type dnsRecordUpsertRequest struct {
|
||||
State *bool `json:"state"`
|
||||
Group string `json:"group"`
|
||||
Host string `json:"host"`
|
||||
Priority int64 `json:"priority"`
|
||||
Weight int64 `json:"weight"`
|
||||
Port int64 `json:"port"`
|
||||
Flags int64 `json:"flags"`
|
||||
Tag string `json:"tag"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
type domainUpsertRequest struct {
|
||||
Name string `json:"name"`
|
||||
IPv4Address string `json:"ipv4Address"`
|
||||
IPv6Address string `json:"ipv6Address"`
|
||||
TTL int64 `json:"ttl"`
|
||||
Group string `json:"group"`
|
||||
}
|
||||
|
||||
func NewServer() *Server {
|
||||
@@ -145,6 +158,9 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/dns":
|
||||
s.writeJSON(w, http.StatusOK, map[string]any{"statusCode": 200, "domains": s.fixture.Domains})
|
||||
return
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/dns":
|
||||
s.handleCreateDomain(w, r)
|
||||
return
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/dns/getroot/"):
|
||||
hostname := strings.TrimPrefix(r.URL.Path, "/dns/getroot/")
|
||||
root, ok := s.fixture.RootsByHostname[hostname]
|
||||
@@ -172,7 +188,7 @@ func (s *Server) serveDNSPath(w http.ResponseWriter, r *http.Request) {
|
||||
trimmed := strings.TrimPrefix(r.URL.Path, "/dns/")
|
||||
segments := strings.Split(trimmed, "/")
|
||||
if len(segments) == 1 {
|
||||
s.serveDomainByID(w, r.Method, segments[0])
|
||||
s.serveDomainByID(w, r, segments[0])
|
||||
return
|
||||
}
|
||||
|
||||
@@ -184,18 +200,27 @@ func (s *Server) serveDNSPath(w http.ResponseWriter, r *http.Request) {
|
||||
s.writeAPIError(w, APIError{HTTPStatus: http.StatusNotFound, StatusCode: 404, Type: "Not Found", Message: "endpoint not found"})
|
||||
}
|
||||
|
||||
func (s *Server) serveDomainByID(w http.ResponseWriter, method string, rawDomainID string) {
|
||||
if method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
func (s *Server) serveDomainByID(w http.ResponseWriter, r *http.Request, rawDomainID string) {
|
||||
domainID, err := strconv.ParseInt(rawDomainID, 10, 64)
|
||||
if err != nil {
|
||||
s.writeAPIError(w, APIError{HTTPStatus: http.StatusBadRequest, StatusCode: 400, Type: "Validation Exception", Message: "invalid domain id: " + rawDomainID})
|
||||
return
|
||||
}
|
||||
for _, domain := range s.fixture.Domains {
|
||||
for idx, domain := range s.fixture.Domains {
|
||||
if domain.ID == domainID {
|
||||
if r.Method == http.MethodDelete {
|
||||
s.fixture.Domains = append(s.fixture.Domains[:idx], s.fixture.Domains[idx+1:]...)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
s.handleUpdateDomain(w, r, idx)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
payload := map[string]any{"statusCode": 200}
|
||||
b, _ := json.Marshal(domain)
|
||||
_ = json.Unmarshal(b, &payload)
|
||||
@@ -206,6 +231,42 @@ func (s *Server) serveDomainByID(w http.ResponseWriter, method string, rawDomain
|
||||
s.writeAPIError(w, APIError{HTTPStatus: http.StatusNotFound, StatusCode: 404, Type: "Not Found", Message: "domain not found"})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateDomain(w http.ResponseWriter, r *http.Request) {
|
||||
var req domainUpsertRequest
|
||||
if err := decodeJSONBody(r.Body, &req); err != nil {
|
||||
s.writeAPIError(w, APIError{HTTPStatus: http.StatusBadRequest, StatusCode: 400, Type: "Validation Exception", Message: "invalid request body"})
|
||||
return
|
||||
}
|
||||
id := int64(len(s.fixture.Domains) + 5000)
|
||||
domain := dynuclient.Domain{ID: id, Name: req.Name, UnicodeName: req.Name, IPv4Address: req.IPv4Address, IPv6Address: req.IPv6Address, TTL: req.TTL, Group: req.Group, State: "active", Token: fmt.Sprintf("tok-%d", id)}
|
||||
s.fixture.Domains = append(s.fixture.Domains, domain)
|
||||
payload := map[string]any{"statusCode": 200}
|
||||
b, _ := json.Marshal(domain)
|
||||
_ = json.Unmarshal(b, &payload)
|
||||
s.writeJSON(w, http.StatusOK, payload)
|
||||
}
|
||||
func (s *Server) handleUpdateDomain(w http.ResponseWriter, r *http.Request, idx int) {
|
||||
var req domainUpsertRequest
|
||||
if err := decodeJSONBody(r.Body, &req); err != nil {
|
||||
s.writeAPIError(w, APIError{HTTPStatus: http.StatusBadRequest, StatusCode: 400, Type: "Validation Exception", Message: "invalid request body"})
|
||||
return
|
||||
}
|
||||
domain := s.fixture.Domains[idx]
|
||||
domain.Name = req.Name
|
||||
domain.UnicodeName = req.Name
|
||||
domain.IPv4Address = req.IPv4Address
|
||||
domain.IPv6Address = req.IPv6Address
|
||||
if req.TTL != 0 {
|
||||
domain.TTL = req.TTL
|
||||
}
|
||||
domain.Group = req.Group
|
||||
s.fixture.Domains[idx] = domain
|
||||
payload := map[string]any{"statusCode": 200}
|
||||
b, _ := json.Marshal(domain)
|
||||
_ = json.Unmarshal(b, &payload)
|
||||
s.writeJSON(w, http.StatusOK, payload)
|
||||
}
|
||||
|
||||
func (s *Server) serveRecordRoutes(w http.ResponseWriter, r *http.Request, segments []string) {
|
||||
domainID, err := strconv.ParseInt(segments[0], 10, 64)
|
||||
if err != nil {
|
||||
@@ -306,6 +367,12 @@ func (s *Server) handleCreateRecord(w http.ResponseWriter, r *http.Request, doma
|
||||
UpdatedOn: time.Now().UTC().Format(time.RFC3339),
|
||||
Group: req.Group,
|
||||
Host: req.Host,
|
||||
Priority: req.Priority,
|
||||
Weight: req.Weight,
|
||||
Port: req.Port,
|
||||
Flags: req.Flags,
|
||||
Tag: req.Tag,
|
||||
Value: req.Value,
|
||||
}
|
||||
|
||||
s.fixture.RecordsByDomain[domainID] = append(s.fixture.RecordsByDomain[domainID], record)
|
||||
@@ -336,6 +403,12 @@ func (s *Server) handleUpdateRecord(w http.ResponseWriter, r *http.Request, doma
|
||||
}
|
||||
record.Group = req.Group
|
||||
record.Host = req.Host
|
||||
record.Priority = req.Priority
|
||||
record.Weight = req.Weight
|
||||
record.Port = req.Port
|
||||
record.Flags = req.Flags
|
||||
record.Tag = req.Tag
|
||||
record.Value = req.Value
|
||||
record.Hostname = buildHostname(req.NodeName, record.DomainName)
|
||||
record.UpdatedOn = time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
@@ -353,6 +426,14 @@ func contentFromUpsertRequest(req dnsRecordUpsertRequest) string {
|
||||
if strings.TrimSpace(req.Host) != "" {
|
||||
return strings.TrimSpace(req.Host)
|
||||
}
|
||||
case "MX", "SRV", "NS", "PTR":
|
||||
if strings.TrimSpace(req.Host) != "" {
|
||||
return strings.TrimSpace(req.Host)
|
||||
}
|
||||
case "CAA":
|
||||
if strings.TrimSpace(req.Value) != "" {
|
||||
return strings.TrimSpace(req.Value)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(req.Content)
|
||||
}
|
||||
@@ -385,6 +466,15 @@ func (s *Server) decodeUpsertRequest(w http.ResponseWriter, r *http.Request) (dn
|
||||
return req, true
|
||||
}
|
||||
|
||||
func decodeJSONBody(body io.ReadCloser, target any) error {
|
||||
defer body.Close()
|
||||
payload, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(payload, target)
|
||||
}
|
||||
|
||||
func (s *Server) findRecord(domainID int64, recordID int64) (dynuclient.DNSRecord, int, bool) {
|
||||
records := s.fixture.RecordsByDomain[domainID]
|
||||
for idx, record := range records {
|
||||
|
||||
Reference in New Issue
Block a user