Validate domain resource and extend DNS record typed fields

This commit is contained in:
beatz174-bit
2026-05-04 00:48:54 +10:00
parent 6badcf35f4
commit e59ad5a65b
9 changed files with 599 additions and 20 deletions
+78 -10
View File
@@ -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
}
+88
View File
@@ -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)
}
}
+1
View File
@@ -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,
}
}
+64 -2
View File
@@ -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
}
+142
View File
@@ -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)
}
}
+97 -7
View File
@@ -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 {