Archived
Add dynu_dns_record CRUD resource and write-capable client
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
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`)
|
||||
|
||||
@@ -96,7 +96,40 @@ Optional arguments:
|
||||
- `base_url` (String)
|
||||
- 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
|
||||
|
||||
@@ -162,6 +195,8 @@ data "dynu_dns_records" "selected" {
|
||||
- `examples/data-sources/dynu_domains/data-source.tf`
|
||||
- `examples/data-sources/dynu_domain/data-source.tf`
|
||||
- `examples/data-sources/dynu_dns_records/data-source.tf`
|
||||
- Resource snippet:
|
||||
- `examples/resources/dynu_dns_record/resource.tf`
|
||||
|
||||
## Troubleshooting local dev
|
||||
|
||||
@@ -211,7 +246,8 @@ 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_dns_record` (CRUD + import using `domain_id/record_id`)
|
||||
|
||||
Not implemented yet:
|
||||
- Terraform resources (create/update/delete)
|
||||
- Any write API operations
|
||||
- Additional Terraform resources beyond `dynu_dns_record`
|
||||
- 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
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package dynuclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -106,6 +107,26 @@ type DNSRecord struct {
|
||||
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 {
|
||||
apiResponse
|
||||
Domains []Domain `json:"domains"`
|
||||
@@ -121,6 +142,11 @@ type listDNSRecordsResponse struct {
|
||||
DNSRecords []DNSRecord `json:"dnsRecords"`
|
||||
}
|
||||
|
||||
type getDNSRecordResponse struct {
|
||||
apiResponse
|
||||
DNSRecord
|
||||
}
|
||||
|
||||
type getRootResponse struct {
|
||||
apiResponse
|
||||
ID int64 `json:"id"`
|
||||
@@ -131,7 +157,7 @@ type getRootResponse struct {
|
||||
|
||||
func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) {
|
||||
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 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) {
|
||||
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 &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) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
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 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
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("API-Key", c.apiKey)
|
||||
if requestBody != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
res, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -195,8 +262,10 @@ 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)))
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(payload, target); err != nil {
|
||||
return fmt.Errorf("failed to decode dynu API response: %w", err)
|
||||
if len(bytes.TrimSpace(payload)) > 0 && target != nil {
|
||||
if err := json.Unmarshal(payload, target); err != nil {
|
||||
return fmt.Errorf("failed to decode dynu API response: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if apiErr != nil {
|
||||
|
||||
@@ -2,10 +2,10 @@ package dynuclient_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/dynu/terraform-provider-dynu/internal/dynuclient"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dynu/terraform-provider-dynu/internal/dynuclient"
|
||||
"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()
|
||||
defer fake.Close()
|
||||
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()
|
||||
defer fake.Close()
|
||||
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()
|
||||
defer fake.Close()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func (p *dynuProvider) Metadata(_ context.Context, _ provider.MetadataRequest, r
|
||||
|
||||
func (p *dynuProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) {
|
||||
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{
|
||||
"api_key": schema.StringAttribute{
|
||||
Optional: true,
|
||||
@@ -78,7 +78,7 @@ func (p *dynuProvider) Configure(ctx context.Context, req provider.ConfigureRequ
|
||||
|
||||
providerData := &providerData{client: newDynuClient(apiKey, data.BaseURL)}
|
||||
resp.DataSourceData = providerData
|
||||
resp.ResourceData = nil
|
||||
resp.ResourceData = providerData
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil
|
||||
return []func() resource.Resource{
|
||||
NewDNSRecordResource,
|
||||
}
|
||||
}
|
||||
|
||||
func newDynuClient(apiKey string, baseURL types.String) *dynuclient.Client {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,13 @@ package fakedynu
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dynu/terraform-provider-dynu/internal/dynuclient"
|
||||
)
|
||||
@@ -39,6 +41,7 @@ type Server struct {
|
||||
fixture Fixture
|
||||
errors map[string]APIError
|
||||
rawPayload map[string]rawResponse
|
||||
nextIDs map[int64]int64
|
||||
}
|
||||
|
||||
type rawResponse struct {
|
||||
@@ -46,10 +49,21 @@ type rawResponse struct {
|
||||
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 {
|
||||
s := &Server{
|
||||
errors: map[string]APIError{},
|
||||
rawPayload: map[string]rawResponse{},
|
||||
nextIDs: map[int64]int64{},
|
||||
fixture: Fixture{
|
||||
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"},
|
||||
@@ -68,6 +82,7 @@ func NewServer() *Server {
|
||||
},
|
||||
}
|
||||
|
||||
s.reseedNextIDs()
|
||||
s.Server = httptest.NewServer(http.HandlerFunc(s.serveHTTP))
|
||||
return s
|
||||
}
|
||||
@@ -80,6 +95,7 @@ func (s *Server) SetFixture(fixture Fixture) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.fixture = fixture
|
||||
s.reseedNextIDs()
|
||||
}
|
||||
|
||||
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}
|
||||
}
|
||||
|
||||
func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
func (s *Server) DeleteRecord(domainID int64, recordID int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
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()
|
||||
defer s.mu.RUnlock()
|
||||
func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if raw, ok := s.rawPayload[r.URL.Path]; ok {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -116,10 +140,10 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
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})
|
||||
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/")
|
||||
root, ok := s.fixture.RootsByHostname[hostname]
|
||||
if !ok {
|
||||
@@ -134,37 +158,266 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
"node": root.Node,
|
||||
})
|
||||
return
|
||||
case strings.HasSuffix(r.URL.Path, "/record"):
|
||||
domainID, err := domainIDFromPath(strings.TrimSuffix(r.URL.Path, "/record"))
|
||||
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})
|
||||
case strings.HasPrefix(r.URL.Path, "/dns/"):
|
||||
s.serveDNSPath(w, r)
|
||||
return
|
||||
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
|
||||
}
|
||||
for _, domain := range s.fixture.Domains {
|
||||
if domain.ID == domainID {
|
||||
payload := map[string]any{"statusCode": 200}
|
||||
b, _ := json.Marshal(domain)
|
||||
_ = json.Unmarshal(b, &payload)
|
||||
s.writeJSON(w, http.StatusOK, payload)
|
||||
return
|
||||
}
|
||||
for _, domain := range s.fixture.Domains {
|
||||
if domain.ID == domainID {
|
||||
payload := map[string]any{"statusCode": 200}
|
||||
b, _ := json.Marshal(domain)
|
||||
_ = json.Unmarshal(b, &payload)
|
||||
s.writeJSON(w, http.StatusOK, payload)
|
||||
return
|
||||
}
|
||||
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.writeAPIError(w, APIError{HTTPStatus: http.StatusNotFound, StatusCode: 404, Type: "Not Found", Message: "domain not found"})
|
||||
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) {
|
||||
trimmed := strings.TrimPrefix(path, "/dns/")
|
||||
if trimmed == path {
|
||||
|
||||
Reference in New Issue
Block a user