Add dynu_dns_record CRUD resource and write-capable client

This commit is contained in:
beatz174-bit
2026-04-23 13:52:55 +10:00
parent d6b52c1f44
commit 7d636b6371
9 changed files with 970 additions and 49 deletions
+40 -4
View File
@@ -2,7 +2,7 @@
A standalone Terraform provider for Dynu DNS. A standalone Terraform provider for Dynu DNS.
> Status: **read-only milestone**. This provider currently implements provider configuration plus read-only data sources. > Status: **early CRUD milestone**. This provider includes read-only data sources plus one writable resource (`dynu_dns_record`) to establish CRUD foundations.
## Quick start (local dev with `dev_overrides`) ## Quick start (local dev with `dev_overrides`)
@@ -96,7 +96,40 @@ Optional arguments:
- `base_url` (String) - `base_url` (String)
- Test/dev override for Dynu API base URL. - Test/dev override for Dynu API base URL.
No provider resources are implemented yet. Provider resources:
- `dynu_dns_record` (first writable resource)
### `dynu_dns_record`
Arguments:
- `hostname` (String, required)
- `record_type` (String, required)
- `content` (String, required)
- `ttl` (Number, optional)
- `state` (Bool, optional)
- `group` (String, optional)
- `host` (String, optional)
- `node_name` (String, optional)
Attributes:
- `id` (String) in `domain_id/record_id` format
- `domain_id` (Number)
- `domain_name` (String)
- `updated_on` (String)
- plus all configurable arguments
Example:
```hcl
resource "dynu_dns_record" "txt" {
hostname = "api.example.com"
record_type = "TXT"
content = "hello-from-terraform"
ttl = 300
state = true
}
```
## Data source schema reference ## Data source schema reference
@@ -162,6 +195,8 @@ data "dynu_dns_records" "selected" {
- `examples/data-sources/dynu_domains/data-source.tf` - `examples/data-sources/dynu_domains/data-source.tf`
- `examples/data-sources/dynu_domain/data-source.tf` - `examples/data-sources/dynu_domain/data-source.tf`
- `examples/data-sources/dynu_dns_records/data-source.tf` - `examples/data-sources/dynu_dns_records/data-source.tf`
- Resource snippet:
- `examples/resources/dynu_dns_record/resource.tf`
## Troubleshooting local dev ## Troubleshooting local dev
@@ -211,7 +246,8 @@ Implemented:
- Provider authentication via `api_key` or `DYNU_API_KEY` - Provider authentication via `api_key` or `DYNU_API_KEY`
- Optional provider `base_url` override - Optional provider `base_url` override
- Data sources: `dynu_domains`, `dynu_domain`, `dynu_dns_records` - Data sources: `dynu_domains`, `dynu_domain`, `dynu_dns_records`
- Resource: `dynu_dns_record` (CRUD + import using `domain_id/record_id`)
Not implemented yet: Not implemented yet:
- Terraform resources (create/update/delete) - Additional Terraform resources beyond `dynu_dns_record`
- Any write API operations - Broader Dynu API coverage outside current DNS/domain scope
@@ -0,0 +1,7 @@
resource "dynu_dns_record" "txt" {
hostname = "api.example.com"
record_type = "TXT"
content = "managed-by-terraform"
ttl = 300
state = true
}
+75 -6
View File
@@ -1,6 +1,7 @@
package dynuclient package dynuclient
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -106,6 +107,26 @@ type DNSRecord struct {
Host string `json:"host"` Host string `json:"host"`
} }
type CreateDNSRecordRequest struct {
NodeName string `json:"nodeName,omitempty"`
RecordType string `json:"recordType"`
Content string `json:"content"`
TTL int64 `json:"ttl,omitempty"`
State *bool `json:"state,omitempty"`
Group string `json:"group,omitempty"`
Host string `json:"host,omitempty"`
}
type UpdateDNSRecordRequest struct {
NodeName string `json:"nodeName,omitempty"`
RecordType string `json:"recordType"`
Content string `json:"content"`
TTL int64 `json:"ttl,omitempty"`
State *bool `json:"state,omitempty"`
Group string `json:"group,omitempty"`
Host string `json:"host,omitempty"`
}
type listDomainsResponse struct { type listDomainsResponse struct {
apiResponse apiResponse
Domains []Domain `json:"domains"` Domains []Domain `json:"domains"`
@@ -121,6 +142,11 @@ type listDNSRecordsResponse struct {
DNSRecords []DNSRecord `json:"dnsRecords"` DNSRecords []DNSRecord `json:"dnsRecords"`
} }
type getDNSRecordResponse struct {
apiResponse
DNSRecord
}
type getRootResponse struct { type getRootResponse struct {
apiResponse apiResponse
ID int64 `json:"id"` ID int64 `json:"id"`
@@ -131,7 +157,7 @@ type getRootResponse struct {
func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) { func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) {
var resp listDomainsResponse var resp listDomainsResponse
if err := c.doGET(ctx, "/dns", &resp); err != nil { if err := c.doRequest(ctx, http.MethodGet, "/dns", nil, &resp); err != nil {
return nil, err return nil, err
} }
return resp.Domains, nil return resp.Domains, nil
@@ -139,7 +165,7 @@ func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) {
func (c *Client) GetDomainByID(ctx context.Context, domainID int64) (*Domain, error) { func (c *Client) GetDomainByID(ctx context.Context, domainID int64) (*Domain, error) {
var resp getDomainResponse var resp getDomainResponse
if err := c.doGET(ctx, fmt.Sprintf("/dns/%d", domainID), &resp); err != nil { if err := c.doRequest(ctx, http.MethodGet, fmt.Sprintf("/dns/%d", domainID), nil, &resp); err != nil {
return nil, err return nil, err
} }
return &resp.Domain, nil return &resp.Domain, nil
@@ -147,7 +173,7 @@ func (c *Client) GetDomainByID(ctx context.Context, domainID int64) (*Domain, er
func (c *Client) GetRootDomain(ctx context.Context, hostname string) (int64, string, error) { func (c *Client) GetRootDomain(ctx context.Context, hostname string) (int64, string, error) {
var resp getRootResponse var resp getRootResponse
if err := c.doGET(ctx, fmt.Sprintf("/dns/getroot/%s", url.PathEscape(hostname)), &resp); err != nil { if err := c.doRequest(ctx, http.MethodGet, fmt.Sprintf("/dns/getroot/%s", url.PathEscape(hostname)), nil, &resp); err != nil {
return 0, "", err return 0, "", err
} }
@@ -160,21 +186,62 @@ func (c *Client) GetRootDomain(ctx context.Context, hostname string) (int64, str
func (c *Client) ListDNSRecords(ctx context.Context, domainID int64) ([]DNSRecord, error) { func (c *Client) ListDNSRecords(ctx context.Context, domainID int64) ([]DNSRecord, error) {
var resp listDNSRecordsResponse var resp listDNSRecordsResponse
if err := c.doGET(ctx, fmt.Sprintf("/dns/%d/record", domainID), &resp); err != nil { if err := c.doRequest(ctx, http.MethodGet, fmt.Sprintf("/dns/%d/record", domainID), nil, &resp); err != nil {
return nil, err return nil, err
} }
return resp.DNSRecords, nil return resp.DNSRecords, nil
} }
func (c *Client) doGET(ctx context.Context, path string, target any) error { func (c *Client) GetDNSRecord(ctx context.Context, domainID int64, recordID int64) (*DNSRecord, error) {
var resp getDNSRecordResponse
if err := c.doRequest(ctx, http.MethodGet, fmt.Sprintf("/dns/%d/record/%d", domainID, recordID), nil, &resp); err != nil {
return nil, err
}
return &resp.DNSRecord, nil
}
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), req, &resp); err != nil {
return nil, err
}
return &resp.DNSRecord, nil
}
func (c *Client) UpdateDNSRecord(ctx context.Context, domainID int64, recordID int64, req UpdateDNSRecordRequest) (*DNSRecord, error) {
var resp getDNSRecordResponse
if err := c.doRequest(ctx, http.MethodPut, fmt.Sprintf("/dns/%d/record/%d", domainID, recordID), req, &resp); err != nil {
return nil, err
}
return &resp.DNSRecord, nil
}
func (c *Client) DeleteDNSRecord(ctx context.Context, domainID int64, recordID int64) error {
return c.doRequest(ctx, http.MethodDelete, fmt.Sprintf("/dns/%d/record/%d", domainID, recordID), nil, nil)
}
func (c *Client) doRequest(ctx context.Context, method string, path string, requestBody any, target any) error {
requestURL := c.baseURL + path requestURL := c.baseURL + path
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
var bodyReader io.Reader
if requestBody != nil {
body, err := json.Marshal(requestBody)
if err != nil {
return fmt.Errorf("failed to encode dynu API request: %w", err)
}
bodyReader = bytes.NewBuffer(body)
}
req, err := http.NewRequestWithContext(ctx, method, requestURL, bodyReader)
if err != nil { if err != nil {
return err return err
} }
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("API-Key", c.apiKey) req.Header.Set("API-Key", c.apiKey)
if requestBody != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := c.httpClient.Do(req) res, err := c.httpClient.Do(req)
if err != nil { if err != nil {
@@ -195,9 +262,11 @@ func (c *Client) doGET(ctx context.Context, path string, target any) error {
return fmt.Errorf("dynu API returned status %d: %s", res.StatusCode, strings.TrimSpace(string(payload))) return fmt.Errorf("dynu API returned status %d: %s", res.StatusCode, strings.TrimSpace(string(payload)))
} }
if len(bytes.TrimSpace(payload)) > 0 && target != nil {
if err := json.Unmarshal(payload, target); err != nil { if err := json.Unmarshal(payload, target); err != nil {
return fmt.Errorf("failed to decode dynu API response: %w", err) return fmt.Errorf("failed to decode dynu API response: %w", err)
} }
}
if apiErr != nil { if apiErr != nil {
return apiErr return apiErr
+69 -4
View File
@@ -2,10 +2,10 @@ package dynuclient_test
import ( import (
"context" "context"
"github.com/dynu/terraform-provider-dynu/internal/dynuclient"
"strings" "strings"
"testing" "testing"
"github.com/dynu/terraform-provider-dynu/internal/dynuclient"
"github.com/dynu/terraform-provider-dynu/internal/testutil/fakedynu" "github.com/dynu/terraform-provider-dynu/internal/testutil/fakedynu"
) )
@@ -23,7 +23,7 @@ func TestClientListDomainsSuccess(t *testing.T) {
} }
} }
func TestClientDoGetNon2xxStatus(t *testing.T) { func TestClientDoRequestNon2xxStatus(t *testing.T) {
fake := fakedynu.NewServer() fake := fakedynu.NewServer()
defer fake.Close() defer fake.Close()
fake.SetRawResponse("/dns", 401, `{"message":"nope"}`) fake.SetRawResponse("/dns", 401, `{"message":"nope"}`)
@@ -35,7 +35,7 @@ func TestClientDoGetNon2xxStatus(t *testing.T) {
} }
} }
func TestClientDoGetAPIExceptionPayload(t *testing.T) { func TestClientDoRequestAPIExceptionPayload(t *testing.T) {
fake := fakedynu.NewServer() fake := fakedynu.NewServer()
defer fake.Close() defer fake.Close()
fake.SetAPIError("/dns", fakedynu.APIError{HTTPStatus: 400, StatusCode: 400, Type: "Validation Exception", Message: "bad hostname"}) fake.SetAPIError("/dns", fakedynu.APIError{HTTPStatus: 400, StatusCode: 400, Type: "Validation Exception", Message: "bad hostname"})
@@ -47,7 +47,7 @@ func TestClientDoGetAPIExceptionPayload(t *testing.T) {
} }
} }
func TestClientDoGetMalformedJSON(t *testing.T) { func TestClientDoRequestMalformedJSON(t *testing.T) {
fake := fakedynu.NewServer() fake := fakedynu.NewServer()
defer fake.Close() defer fake.Close()
fake.SetRawResponse("/dns", 200, `{"statusCode":200,"domains":[`) fake.SetRawResponse("/dns", 200, `{"statusCode":200,"domains":[`)
@@ -82,3 +82,68 @@ func TestClientGetRootDomainEscapesHostname(t *testing.T) {
t.Fatalf("expected hostname not found error, got %v", err) t.Fatalf("expected hostname not found error, got %v", err)
} }
} }
func TestClientDNSRecordCRUD(t *testing.T) {
fake := fakedynu.NewServer()
defer fake.Close()
client := dynuclient.New("test-key", dynuclient.WithBaseURL(fake.BaseURL()), dynuclient.WithHTTPClient(fake.Client()))
state := true
created, err := client.CreateDNSRecord(context.Background(), 1001, dynuclient.CreateDNSRecordRequest{
NodeName: "api",
RecordType: "TXT",
Content: "created",
TTL: 120,
State: &state,
Group: "integration",
})
if err != nil {
t.Fatalf("CreateDNSRecord() error = %v", err)
}
if created.ID == 0 {
t.Fatal("expected created record id")
}
got, err := client.GetDNSRecord(context.Background(), 1001, created.ID)
if err != nil {
t.Fatalf("GetDNSRecord() error = %v", err)
}
if got.Content != "created" {
t.Fatalf("expected content created, got %q", got.Content)
}
updated, err := client.UpdateDNSRecord(context.Background(), 1001, created.ID, dynuclient.UpdateDNSRecordRequest{
NodeName: "api",
RecordType: "TXT",
Content: "updated",
TTL: 180,
State: &state,
})
if err != nil {
t.Fatalf("UpdateDNSRecord() error = %v", err)
}
if updated.Content != "updated" || updated.TTL != 180 {
t.Fatalf("unexpected update response: %#v", updated)
}
if err := client.DeleteDNSRecord(context.Background(), 1001, created.ID); err != nil {
t.Fatalf("DeleteDNSRecord() error = %v", err)
}
_, err = client.GetDNSRecord(context.Background(), 1001, created.ID)
if err == nil || !strings.Contains(err.Error(), "record not found") {
t.Fatalf("expected record not found after delete, got %v", err)
}
}
func TestClientDNSRecordWriteAPIError(t *testing.T) {
fake := fakedynu.NewServer()
defer fake.Close()
fake.SetAPIError("/dns/1001/record", fakedynu.APIError{HTTPStatus: 400, StatusCode: 400, Type: "Validation Exception", Message: "recordType invalid"})
client := dynuclient.New("test-key", dynuclient.WithBaseURL(fake.BaseURL()), dynuclient.WithHTTPClient(fake.Client()))
_, err := client.CreateDNSRecord(context.Background(), 1001, dynuclient.CreateDNSRecordRequest{RecordType: "", Content: "x"})
if err == nil || !strings.Contains(err.Error(), "Validation Exception") {
t.Fatalf("expected validation API error, got %v", err)
}
}
+5 -3
View File
@@ -43,7 +43,7 @@ func (p *dynuProvider) Metadata(_ context.Context, _ provider.MetadataRequest, r
func (p *dynuProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) { func (p *dynuProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) {
resp.Schema = schema.Schema{ resp.Schema = schema.Schema{
Description: "Terraform provider for Dynu DNS read-only data sources.", Description: "Terraform provider for Dynu DNS domains, records, and data sources.",
Attributes: map[string]schema.Attribute{ Attributes: map[string]schema.Attribute{
"api_key": schema.StringAttribute{ "api_key": schema.StringAttribute{
Optional: true, Optional: true,
@@ -78,7 +78,7 @@ func (p *dynuProvider) Configure(ctx context.Context, req provider.ConfigureRequ
providerData := &providerData{client: newDynuClient(apiKey, data.BaseURL)} providerData := &providerData{client: newDynuClient(apiKey, data.BaseURL)}
resp.DataSourceData = providerData resp.DataSourceData = providerData
resp.ResourceData = nil resp.ResourceData = providerData
} }
func resolveAPIKey(configValue types.String, envValue string) string { func resolveAPIKey(configValue types.String, envValue string) string {
@@ -97,7 +97,9 @@ func (p *dynuProvider) DataSources(_ context.Context) []func() datasource.DataSo
} }
func (p *dynuProvider) Resources(_ context.Context) []func() resource.Resource { func (p *dynuProvider) Resources(_ context.Context) []func() resource.Resource {
return nil return []func() resource.Resource{
NewDNSRecordResource,
}
} }
func newDynuClient(apiKey string, baseURL types.String) *dynuclient.Client { func newDynuClient(apiKey string, baseURL types.String) *dynuclient.Client {
+316
View File
@@ -0,0 +1,316 @@
package provider
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/dynu/terraform-provider-dynu/internal/dynuclient"
)
var (
_ resource.Resource = &dnsRecordResource{}
_ resource.ResourceWithConfigure = &dnsRecordResource{}
_ resource.ResourceWithImportState = &dnsRecordResource{}
)
type dnsRecordResource struct {
clientProvider *providerData
}
type dnsRecordResourceModel struct {
ID types.String `tfsdk:"id"`
Hostname types.String `tfsdk:"hostname"`
RecordType types.String `tfsdk:"record_type"`
Content types.String `tfsdk:"content"`
TTL types.Int64 `tfsdk:"ttl"`
State types.Bool `tfsdk:"state"`
Group types.String `tfsdk:"group"`
Host types.String `tfsdk:"host"`
NodeName types.String `tfsdk:"node_name"`
DomainID types.Int64 `tfsdk:"domain_id"`
DomainName types.String `tfsdk:"domain_name"`
UpdatedOn types.String `tfsdk:"updated_on"`
}
func NewDNSRecordResource() resource.Resource {
return &dnsRecordResource{}
}
func (r *dnsRecordResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_dns_record"
}
func (r *dnsRecordResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Manages a Dynu DNS record for the root domain resolved from hostname.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{Computed: true, Description: "Resource identifier in domain_id/record_id format."},
"hostname": schema.StringAttribute{
Required: true,
Description: "Fully-qualified hostname under the target root domain.",
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
stringvalidator.RegexMatches(hostnameValidator, "must be a valid fully-qualified hostname"),
},
},
"record_type": schema.StringAttribute{Required: true, Description: "DNS record type (A, AAAA, CNAME, TXT, etc.).", Validators: []validator.String{stringvalidator.LengthAtLeast(1)}},
"content": schema.StringAttribute{Required: true, Description: "Record content/value."},
"ttl": schema.Int64Attribute{
Optional: true,
Computed: true,
Description: "DNS TTL in seconds.",
Validators: []validator.Int64{int64validator.AtLeast(0)},
},
"state": schema.BoolAttribute{Optional: true, Computed: true, Description: "Whether this DNS record is 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."},
"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."},
"updated_on": schema.StringAttribute{Computed: true, Description: "Last update timestamp as returned by Dynu."},
},
}
}
func (r *dnsRecordResource) 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 *dnsRecordResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan dnsRecordResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
domainID, domainName, err := r.clientProvider.client.GetRootDomain(ctx, plan.Hostname.ValueString())
if err != nil {
resp.Diagnostics.AddError(diagnosticSummary("Unable to resolve Dynu domain from hostname", err), err.Error())
return
}
createReq := dynuclient.CreateDNSRecordRequest{
NodeName: recordNodeName(plan.NodeName, plan.Hostname, domainName),
RecordType: strings.TrimSpace(plan.RecordType.ValueString()),
Content: strings.TrimSpace(plan.Content.ValueString()),
TTL: int64FromOptional(plan.TTL),
State: boolPointerFromOptional(plan.State),
Group: stringFromOptional(plan.Group),
Host: stringFromOptional(plan.Host),
}
record, err := r.clientProvider.client.CreateDNSRecord(ctx, domainID, createReq)
if err != nil {
resp.Diagnostics.AddError(diagnosticSummary("Unable to create Dynu DNS record", err), err.Error())
return
}
state := mapDNSRecordToState(*record)
state.ID = types.StringValue(formatDNSRecordID(record.DomainID, record.ID))
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *dnsRecordResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state dnsRecordResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
domainID, recordID, err := parseDNSRecordID(state.ID.ValueString())
if err != nil {
resp.Diagnostics.AddError("Invalid resource ID", err.Error())
return
}
record, err := r.clientProvider.client.GetDNSRecord(ctx, domainID, recordID)
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 DNS record", err), err.Error())
return
}
nextState := mapDNSRecordToState(*record)
nextState.ID = state.ID
resp.Diagnostics.Append(resp.State.Set(ctx, &nextState)...)
}
func (r *dnsRecordResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan dnsRecordResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
domainID, recordID, err := parseDNSRecordID(plan.ID.ValueString())
if err != nil {
resp.Diagnostics.AddError("Invalid resource ID", err.Error())
return
}
domainName := strings.TrimSpace(plan.DomainName.ValueString())
if domainName == "" {
_, resolvedDomainName, err := r.clientProvider.client.GetRootDomain(ctx, plan.Hostname.ValueString())
if err != nil {
resp.Diagnostics.AddError(diagnosticSummary("Unable to resolve Dynu domain from hostname", err), err.Error())
return
}
domainName = resolvedDomainName
}
updateReq := dynuclient.UpdateDNSRecordRequest{
NodeName: recordNodeName(plan.NodeName, plan.Hostname, domainName),
RecordType: strings.TrimSpace(plan.RecordType.ValueString()),
Content: strings.TrimSpace(plan.Content.ValueString()),
TTL: int64FromOptional(plan.TTL),
State: boolPointerFromOptional(plan.State),
Group: stringFromOptional(plan.Group),
Host: stringFromOptional(plan.Host),
}
if _, err := r.clientProvider.client.UpdateDNSRecord(ctx, domainID, recordID, updateReq); err != nil {
resp.Diagnostics.AddError(diagnosticSummary("Unable to update Dynu DNS record", err), err.Error())
return
}
record, err := r.clientProvider.client.GetDNSRecord(ctx, domainID, recordID)
if err != nil {
resp.Diagnostics.AddError(diagnosticSummary("Unable to read Dynu DNS record", err), err.Error())
return
}
nextState := mapDNSRecordToState(*record)
nextState.ID = types.StringValue(formatDNSRecordID(record.DomainID, record.ID))
resp.Diagnostics.Append(resp.State.Set(ctx, &nextState)...)
}
func (r *dnsRecordResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state dnsRecordResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
domainID, recordID, err := parseDNSRecordID(state.ID.ValueString())
if err != nil {
resp.Diagnostics.AddError("Invalid resource ID", err.Error())
return
}
err = r.clientProvider.client.DeleteDNSRecord(ctx, domainID, recordID)
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 DNS record", err), err.Error())
}
func (r *dnsRecordResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
domainID, recordID, err := parseDNSRecordID(req.ID)
if err != nil {
resp.Diagnostics.AddError("Invalid import ID", err.Error())
return
}
state := dnsRecordResourceModel{ID: types.StringValue(formatDNSRecordID(domainID, recordID))}
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func mapDNSRecordToState(record dynuclient.DNSRecord) dnsRecordResourceModel {
return dnsRecordResourceModel{
Hostname: mapString(record.Hostname),
RecordType: mapString(record.RecordType),
Content: mapString(record.Content),
TTL: types.Int64Value(record.TTL),
State: types.BoolValue(record.State),
Group: mapString(record.Group),
Host: mapString(record.Host),
NodeName: mapString(record.NodeName),
DomainID: types.Int64Value(record.DomainID),
DomainName: mapString(record.DomainName),
UpdatedOn: mapString(record.UpdatedOn),
}
}
func parseDNSRecordID(id string) (int64, int64, error) {
parts := strings.Split(strings.TrimSpace(id), "/")
if len(parts) != 2 {
return 0, 0, fmt.Errorf("expected import ID in domain_id/record_id format")
}
domainID, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || domainID <= 0 {
return 0, 0, fmt.Errorf("invalid domain_id in ID %q", id)
}
recordID, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil || recordID <= 0 {
return 0, 0, fmt.Errorf("invalid record_id in ID %q", id)
}
return domainID, recordID, nil
}
func formatDNSRecordID(domainID int64, recordID int64) string {
return fmt.Sprintf("%d/%d", domainID, recordID)
}
func recordNodeName(nodeName types.String, hostname types.String, domainName string) string {
if !nodeName.IsNull() && !nodeName.IsUnknown() {
return strings.TrimSpace(nodeName.ValueString())
}
host := strings.TrimSpace(hostname.ValueString())
domain := strings.TrimSpace(domainName)
if strings.EqualFold(host, domain) {
return ""
}
suffix := "." + domain
if strings.HasSuffix(strings.ToLower(host), strings.ToLower(suffix)) {
return strings.TrimSuffix(host, suffix)
}
return host
}
func int64FromOptional(value types.Int64) int64 {
if value.IsNull() || value.IsUnknown() {
return 0
}
return value.ValueInt64()
}
func boolPointerFromOptional(value types.Bool) *bool {
if value.IsNull() || value.IsUnknown() {
return nil
}
v := value.ValueBool()
return &v
}
func stringFromOptional(value types.String) string {
if value.IsNull() || value.IsUnknown() {
return ""
}
return strings.TrimSpace(value.ValueString())
}
@@ -0,0 +1,154 @@
package provider
import (
"context"
"strings"
"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 TestIntegrationResourceDNSRecordLifecycleAndImport(t *testing.T) {
ctx := context.Background()
fake := fakedynu.NewServer()
defer fake.Close()
r := NewDNSRecordResource().(*dnsRecordResource)
configureResource(t, r, fake.BaseURL())
var schemaResp resource.SchemaResponse
r.Schema(ctx, resource.SchemaRequest{}, &schemaResp)
createPlan := dnsRecordResourceModel{
Hostname: types.StringValue("api.a.example.com"),
RecordType: types.StringValue("TXT"),
Content: types.StringValue("created"),
TTL: types.Int64Value(60),
State: types.BoolValue(true),
Group: types.StringValue("test"),
Host: types.StringNull(),
NodeName: types.StringNull(),
}
plan := tfsdk.Plan{Schema: schemaResp.Schema}
if diags := plan.Set(ctx, &createPlan); diags.HasError() {
t.Fatalf("set plan diagnostics: %v", diags)
}
createResp := resource.CreateResponse{State: tfsdk.State{Schema: schemaResp.Schema}}
r.Create(ctx, resource.CreateRequest{Plan: plan}, &createResp)
if createResp.Diagnostics.HasError() {
t.Fatalf("create diagnostics: %v", createResp.Diagnostics)
}
var state dnsRecordResourceModel
if diags := createResp.State.Get(ctx, &state); diags.HasError() {
t.Fatalf("state get diagnostics: %v", diags)
}
if !strings.HasPrefix(state.ID.ValueString(), "1001/") {
t.Fatalf("expected composite id, got %q", state.ID.ValueString())
}
if state.Content.ValueString() != "created" {
t.Fatalf("unexpected created content: %q", state.Content.ValueString())
}
updatePlan := state
updatePlan.Content = types.StringValue("updated")
updatePlan.TTL = types.Int64Value(600)
plan = tfsdk.Plan{Schema: schemaResp.Schema}
if diags := plan.Set(ctx, &updatePlan); diags.HasError() {
t.Fatalf("set update plan diagnostics: %v", diags)
}
updateResp := resource.UpdateResponse{State: tfsdk.State{Schema: schemaResp.Schema}}
r.Update(ctx, resource.UpdateRequest{Plan: plan}, &updateResp)
if updateResp.Diagnostics.HasError() {
t.Fatalf("update diagnostics: %v", updateResp.Diagnostics)
}
if diags := updateResp.State.Get(ctx, &state); diags.HasError() {
t.Fatalf("updated state get diagnostics: %v", diags)
}
if state.Content.ValueString() != "updated" || state.TTL.ValueInt64() != 600 {
t.Fatalf("unexpected updated state: %#v", state)
}
importResp := resource.ImportStateResponse{State: tfsdk.State{Schema: schemaResp.Schema}}
r.ImportState(ctx, resource.ImportStateRequest{ID: state.ID.ValueString()}, &importResp)
if importResp.Diagnostics.HasError() {
t.Fatalf("import diagnostics: %v", importResp.Diagnostics)
}
var importedID string
if diags := importResp.State.GetAttribute(ctx, path.Root("id"), &importedID); diags.HasError() {
t.Fatalf("imported id diagnostics: %v", diags)
}
if importedID != state.ID.ValueString() {
t.Fatalf("expected imported id %q, got %q", state.ID.ValueString(), importedID)
}
deleteResp := resource.DeleteResponse{}
r.Delete(ctx, resource.DeleteRequest{State: updateResp.State}, &deleteResp)
if deleteResp.Diagnostics.HasError() {
t.Fatalf("delete diagnostics: %v", deleteResp.Diagnostics)
}
}
func TestIntegrationResourceDNSRecordReadNotFoundRemovesState(t *testing.T) {
ctx := context.Background()
fake := fakedynu.NewServer()
defer fake.Close()
r := NewDNSRecordResource().(*dnsRecordResource)
configureResource(t, r, fake.BaseURL())
var schemaResp resource.SchemaResponse
r.Schema(ctx, resource.SchemaRequest{}, &schemaResp)
state := dnsRecordResourceModel{
ID: types.StringValue("1001/10"),
}
tfState := tfsdk.State{Schema: schemaResp.Schema}
if diags := tfState.Set(ctx, &state); diags.HasError() {
t.Fatalf("set state diagnostics: %v", diags)
}
fake.DeleteRecord(1001, 10)
readResp := resource.ReadResponse{State: tfState}
r.Read(ctx, resource.ReadRequest{State: tfState}, &readResp)
if readResp.Diagnostics.HasError() {
t.Fatalf("read diagnostics: %v", readResp.Diagnostics)
}
var after dnsRecordResourceModel
diags := readResp.State.Get(ctx, &after)
if !diags.HasError() {
t.Fatal("expected diagnostics because resource was removed from state")
}
}
func TestIntegrationResourceDNSRecordImportStateInvalidID(t *testing.T) {
ctx := context.Background()
r := NewDNSRecordResource().(*dnsRecordResource)
importResp := resource.ImportStateResponse{}
r.ImportState(ctx, resource.ImportStateRequest{ID: "bad-id"}, &importResp)
if !importResp.Diagnostics.HasError() {
t.Fatal("expected import diagnostics for malformed ID")
}
if !strings.Contains(importResp.Diagnostics[0].Detail(), "domain_id/record_id") {
t.Fatalf("unexpected diagnostic detail: %s", importResp.Diagnostics[0].Detail())
}
}
func configureResource(t *testing.T, r resource.ResourceWithConfigure, baseURL string) {
t.Helper()
resp := resource.ConfigureResponse{}
r.Configure(context.Background(), resource.ConfigureRequest{
ProviderData: &providerData{client: newDynuClient("dummy-local-key", types.StringValue(baseURL))},
}, &resp)
if resp.Diagnostics.HasError() {
t.Fatalf("configure diagnostics: %v", resp.Diagnostics)
}
}
@@ -0,0 +1,19 @@
package provider
import "testing"
func TestParseDNSRecordID(t *testing.T) {
domainID, recordID, err := parseDNSRecordID("1001/55")
if err != nil {
t.Fatalf("unexpected parse error: %v", err)
}
if domainID != 1001 || recordID != 55 {
t.Fatalf("unexpected ids: domain=%d record=%d", domainID, recordID)
}
}
func TestParseDNSRecordIDInvalid(t *testing.T) {
if _, _, err := parseDNSRecordID("1001"); err == nil {
t.Fatal("expected parse error")
}
}
+274 -21
View File
@@ -3,11 +3,13 @@ package fakedynu
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time"
"github.com/dynu/terraform-provider-dynu/internal/dynuclient" "github.com/dynu/terraform-provider-dynu/internal/dynuclient"
) )
@@ -39,6 +41,7 @@ type Server struct {
fixture Fixture fixture Fixture
errors map[string]APIError errors map[string]APIError
rawPayload map[string]rawResponse rawPayload map[string]rawResponse
nextIDs map[int64]int64
} }
type rawResponse struct { type rawResponse struct {
@@ -46,10 +49,21 @@ type rawResponse struct {
body string body string
} }
type dnsRecordUpsertRequest struct {
NodeName string `json:"nodeName"`
RecordType string `json:"recordType"`
Content string `json:"content"`
TTL int64 `json:"ttl"`
State *bool `json:"state"`
Group string `json:"group"`
Host string `json:"host"`
}
func NewServer() *Server { func NewServer() *Server {
s := &Server{ s := &Server{
errors: map[string]APIError{}, errors: map[string]APIError{},
rawPayload: map[string]rawResponse{}, rawPayload: map[string]rawResponse{},
nextIDs: map[int64]int64{},
fixture: Fixture{ fixture: Fixture{
Domains: []dynuclient.Domain{ Domains: []dynuclient.Domain{
{ID: 2002, Name: "z.example.com", UnicodeName: "z.example.com", TTL: 60, CreatedOn: "2024-01-03T00:00:00", UpdatedOn: "2024-01-04T00:00:00"}, {ID: 2002, Name: "z.example.com", UnicodeName: "z.example.com", TTL: 60, CreatedOn: "2024-01-03T00:00:00", UpdatedOn: "2024-01-04T00:00:00"},
@@ -68,6 +82,7 @@ func NewServer() *Server {
}, },
} }
s.reseedNextIDs()
s.Server = httptest.NewServer(http.HandlerFunc(s.serveHTTP)) s.Server = httptest.NewServer(http.HandlerFunc(s.serveHTTP))
return s return s
} }
@@ -80,6 +95,7 @@ func (s *Server) SetFixture(fixture Fixture) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
s.fixture = fixture s.fixture = fixture
s.reseedNextIDs()
} }
func (s *Server) SetAPIError(path string, apiErr APIError) { func (s *Server) SetAPIError(path string, apiErr APIError) {
@@ -94,14 +110,22 @@ func (s *Server) SetRawResponse(path string, httpStatus int, body string) {
s.rawPayload[path] = rawResponse{httpStatus: httpStatus, body: body} s.rawPayload[path] = rawResponse{httpStatus: httpStatus, body: body}
} }
func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) { func (s *Server) DeleteRecord(domainID int64, recordID int64) {
if r.Method != http.MethodGet { s.mu.Lock()
w.WriteHeader(http.StatusMethodNotAllowed) defer s.mu.Unlock()
return records := s.fixture.RecordsByDomain[domainID]
updated := make([]dynuclient.DNSRecord, 0, len(records))
for _, record := range records {
if record.ID != recordID {
updated = append(updated, record)
} }
}
s.fixture.RecordsByDomain[domainID] = updated
}
s.mu.RLock() func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) {
defer s.mu.RUnlock() s.mu.Lock()
defer s.mu.Unlock()
if raw, ok := s.rawPayload[r.URL.Path]; ok { if raw, ok := s.rawPayload[r.URL.Path]; ok {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -116,10 +140,10 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) {
} }
switch { switch {
case r.URL.Path == "/dns": case r.Method == http.MethodGet && r.URL.Path == "/dns":
s.writeJSON(w, http.StatusOK, map[string]any{"statusCode": 200, "domains": s.fixture.Domains}) s.writeJSON(w, http.StatusOK, map[string]any{"statusCode": 200, "domains": s.fixture.Domains})
return return
case strings.HasPrefix(r.URL.Path, "/dns/getroot/"): case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/dns/getroot/"):
hostname := strings.TrimPrefix(r.URL.Path, "/dns/getroot/") hostname := strings.TrimPrefix(r.URL.Path, "/dns/getroot/")
root, ok := s.fixture.RootsByHostname[hostname] root, ok := s.fixture.RootsByHostname[hostname]
if !ok { if !ok {
@@ -134,22 +158,38 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) {
"node": root.Node, "node": root.Node,
}) })
return return
case strings.HasSuffix(r.URL.Path, "/record"): case strings.HasPrefix(r.URL.Path, "/dns/"):
domainID, err := domainIDFromPath(strings.TrimSuffix(r.URL.Path, "/record")) s.serveDNSPath(w, r)
if err != nil {
s.writeAPIError(w, APIError{HTTPStatus: http.StatusBadRequest, StatusCode: 400, Type: "Validation Exception", Message: err.Error()})
return
}
records, ok := s.fixture.RecordsByDomain[domainID]
if !ok {
records = []dynuclient.DNSRecord{}
}
s.writeJSON(w, http.StatusOK, map[string]any{"statusCode": 200, "dnsRecords": records})
return return
default: default:
domainID, err := domainIDFromPath(r.URL.Path)
if err != nil {
s.writeAPIError(w, APIError{HTTPStatus: http.StatusNotFound, StatusCode: 404, Type: "Not Found", Message: "endpoint not found"}) s.writeAPIError(w, APIError{HTTPStatus: http.StatusNotFound, StatusCode: 404, Type: "Not Found", Message: "endpoint not found"})
}
}
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])
return
}
if len(segments) >= 2 && segments[1] == "record" {
s.serveRecordRoutes(w, r, segments)
return
}
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
}
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 return
} }
for _, domain := range s.fixture.Domains { for _, domain := range s.fixture.Domains {
@@ -162,7 +202,220 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) {
} }
} }
s.writeAPIError(w, APIError{HTTPStatus: http.StatusNotFound, StatusCode: 404, Type: "Not Found", Message: "domain not found"}) s.writeAPIError(w, APIError{HTTPStatus: http.StatusNotFound, StatusCode: 404, Type: "Not Found", Message: "domain not found"})
}
func (s *Server) serveRecordRoutes(w http.ResponseWriter, r *http.Request, segments []string) {
domainID, err := strconv.ParseInt(segments[0], 10, 64)
if err != nil {
s.writeAPIError(w, APIError{HTTPStatus: http.StatusBadRequest, StatusCode: 400, Type: "Validation Exception", Message: "invalid domain id: " + segments[0]})
return
} }
if len(segments) == 2 {
switch r.Method {
case http.MethodGet:
records := s.fixture.RecordsByDomain[domainID]
if records == nil {
records = []dynuclient.DNSRecord{}
}
s.writeJSON(w, http.StatusOK, map[string]any{"statusCode": 200, "dnsRecords": records})
return
case http.MethodPost:
s.handleCreateRecord(w, r, domainID)
return
default:
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
}
if len(segments) == 3 {
recordID, err := strconv.ParseInt(segments[2], 10, 64)
if err != nil {
s.writeAPIError(w, APIError{HTTPStatus: http.StatusBadRequest, StatusCode: 400, Type: "Validation Exception", Message: "invalid record id: " + segments[2]})
return
}
switch r.Method {
case http.MethodGet:
s.handleGetRecord(w, domainID, recordID)
return
case http.MethodPut:
s.handleUpdateRecord(w, r, domainID, recordID)
return
case http.MethodDelete:
s.handleDeleteRecord(w, domainID, recordID)
return
default:
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
}
s.writeAPIError(w, APIError{HTTPStatus: http.StatusNotFound, StatusCode: 404, Type: "Not Found", Message: "endpoint not found"})
}
func (s *Server) handleGetRecord(w http.ResponseWriter, domainID int64, recordID int64) {
record, _, ok := s.findRecord(domainID, recordID)
if !ok {
s.writeAPIError(w, APIError{HTTPStatus: http.StatusNotFound, StatusCode: 404, Type: "Not Found", Message: "record not found"})
return
}
s.writeRecord(w, record)
}
func (s *Server) handleCreateRecord(w http.ResponseWriter, r *http.Request, domainID int64) {
req, ok := s.decodeUpsertRequest(w, r)
if !ok {
return
}
if req.RecordType == "" || req.Content == "" {
s.writeAPIError(w, APIError{HTTPStatus: http.StatusBadRequest, StatusCode: 400, Type: "Validation Exception", Message: "recordType and content are required"})
return
}
domainName := s.domainName(domainID)
if domainName == "" {
s.writeAPIError(w, APIError{HTTPStatus: http.StatusNotFound, StatusCode: 404, Type: "Not Found", Message: "domain not found"})
return
}
state := true
if req.State != nil {
state = *req.State
}
ttl := req.TTL
if ttl <= 0 {
ttl = 90
}
record := dynuclient.DNSRecord{
ID: s.nextRecordID(domainID),
DomainID: domainID,
DomainName: domainName,
NodeName: req.NodeName,
Hostname: buildHostname(req.NodeName, domainName),
RecordType: req.RecordType,
State: state,
TTL: ttl,
Content: req.Content,
UpdatedOn: time.Now().UTC().Format(time.RFC3339),
Group: req.Group,
Host: req.Host,
}
s.fixture.RecordsByDomain[domainID] = append(s.fixture.RecordsByDomain[domainID], record)
s.writeRecord(w, record)
}
func (s *Server) handleUpdateRecord(w http.ResponseWriter, r *http.Request, domainID int64, recordID int64) {
req, ok := s.decodeUpsertRequest(w, r)
if !ok {
return
}
record, idx, found := s.findRecord(domainID, recordID)
if !found {
s.writeAPIError(w, APIError{HTTPStatus: http.StatusNotFound, StatusCode: 404, Type: "Not Found", Message: "record not found"})
return
}
record.NodeName = req.NodeName
record.RecordType = req.RecordType
record.Content = req.Content
if req.TTL > 0 {
record.TTL = req.TTL
}
if req.State != nil {
record.State = *req.State
}
record.Group = req.Group
record.Host = req.Host
record.Hostname = buildHostname(req.NodeName, record.DomainName)
record.UpdatedOn = time.Now().UTC().Format(time.RFC3339)
s.fixture.RecordsByDomain[domainID][idx] = record
s.writeRecord(w, record)
}
func (s *Server) handleDeleteRecord(w http.ResponseWriter, domainID int64, recordID int64) {
_, idx, ok := s.findRecord(domainID, recordID)
if !ok {
s.writeAPIError(w, APIError{HTTPStatus: http.StatusNotFound, StatusCode: 404, Type: "Not Found", Message: "record not found"})
return
}
records := s.fixture.RecordsByDomain[domainID]
s.fixture.RecordsByDomain[domainID] = append(records[:idx], records[idx+1:]...)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) decodeUpsertRequest(w http.ResponseWriter, r *http.Request) (dnsRecordUpsertRequest, bool) {
payload, err := io.ReadAll(r.Body)
if err != nil {
s.writeAPIError(w, APIError{HTTPStatus: http.StatusBadRequest, StatusCode: 400, Type: "Validation Exception", Message: "unable to read request body"})
return dnsRecordUpsertRequest{}, false
}
defer r.Body.Close()
var req dnsRecordUpsertRequest
if err := json.Unmarshal(payload, &req); err != nil {
s.writeAPIError(w, APIError{HTTPStatus: http.StatusBadRequest, StatusCode: 400, Type: "Validation Exception", Message: "invalid json payload"})
return dnsRecordUpsertRequest{}, false
}
return req, true
}
func (s *Server) findRecord(domainID int64, recordID int64) (dynuclient.DNSRecord, int, bool) {
records := s.fixture.RecordsByDomain[domainID]
for idx, record := range records {
if record.ID == recordID {
return record, idx, true
}
}
return dynuclient.DNSRecord{}, -1, false
}
func (s *Server) writeRecord(w http.ResponseWriter, record dynuclient.DNSRecord) {
payload := map[string]any{"statusCode": 200}
b, _ := json.Marshal(record)
_ = json.Unmarshal(b, &payload)
s.writeJSON(w, http.StatusOK, payload)
}
func (s *Server) domainName(domainID int64) string {
for _, domain := range s.fixture.Domains {
if domain.ID == domainID {
return domain.Name
}
}
return ""
}
func (s *Server) nextRecordID(domainID int64) int64 {
nextID := s.nextIDs[domainID]
if nextID == 0 {
nextID = 1
}
s.nextIDs[domainID] = nextID + 1
return nextID
}
func (s *Server) reseedNextIDs() {
s.nextIDs = map[int64]int64{}
for domainID, records := range s.fixture.RecordsByDomain {
maxID := int64(0)
for _, record := range records {
if record.ID > maxID {
maxID = record.ID
}
}
s.nextIDs[domainID] = maxID + 1
}
}
func buildHostname(nodeName string, domainName string) string {
nodeName = strings.TrimSpace(nodeName)
if nodeName == "" || nodeName == "@" {
return domainName
}
return nodeName + "." + domainName
} }
func domainIDFromPath(path string) (int64, error) { func domainIDFromPath(path string) (int64, error) {