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.
|
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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 (
|
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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user