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