From 7d636b63714e25bc98dc099484389e66714b0c5e Mon Sep 17 00:00:00 2001 From: beatz174-bit Date: Thu, 23 Apr 2026 13:52:55 +1000 Subject: [PATCH 1/2] Add dynu_dns_record CRUD resource and write-capable client --- README.md | 44 ++- .../resources/dynu_dns_record/resource.tf | 7 + internal/dynuclient/client.go | 85 ++++- internal/dynuclient/client_test.go | 73 +++- internal/provider/provider.go | 8 +- internal/provider/resource_dns_record.go | 316 ++++++++++++++++++ .../resource_dns_record_integration_test.go | 154 +++++++++ internal/provider/resource_dns_record_test.go | 19 ++ internal/testutil/fakedynu/server.go | 313 +++++++++++++++-- 9 files changed, 970 insertions(+), 49 deletions(-) create mode 100644 examples/resources/dynu_dns_record/resource.tf create mode 100644 internal/provider/resource_dns_record.go create mode 100644 internal/provider/resource_dns_record_integration_test.go create mode 100644 internal/provider/resource_dns_record_test.go diff --git a/README.md b/README.md index 3765f18..33d4047 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A standalone Terraform provider for Dynu DNS. -> Status: **read-only milestone**. This provider currently implements provider configuration plus read-only data sources. +> Status: **early CRUD milestone**. This provider includes read-only data sources plus one writable resource (`dynu_dns_record`) to establish CRUD foundations. ## Quick start (local dev with `dev_overrides`) @@ -96,7 +96,40 @@ Optional arguments: - `base_url` (String) - Test/dev override for Dynu API base URL. -No provider resources are implemented yet. +Provider resources: +- `dynu_dns_record` (first writable resource) + + +### `dynu_dns_record` + +Arguments: +- `hostname` (String, required) +- `record_type` (String, required) +- `content` (String, required) +- `ttl` (Number, optional) +- `state` (Bool, optional) +- `group` (String, optional) +- `host` (String, optional) +- `node_name` (String, optional) + +Attributes: +- `id` (String) in `domain_id/record_id` format +- `domain_id` (Number) +- `domain_name` (String) +- `updated_on` (String) +- plus all configurable arguments + +Example: + +```hcl +resource "dynu_dns_record" "txt" { + hostname = "api.example.com" + record_type = "TXT" + content = "hello-from-terraform" + ttl = 300 + state = true +} +``` ## Data source schema reference @@ -162,6 +195,8 @@ data "dynu_dns_records" "selected" { - `examples/data-sources/dynu_domains/data-source.tf` - `examples/data-sources/dynu_domain/data-source.tf` - `examples/data-sources/dynu_dns_records/data-source.tf` +- Resource snippet: + - `examples/resources/dynu_dns_record/resource.tf` ## Troubleshooting local dev @@ -211,7 +246,8 @@ Implemented: - Provider authentication via `api_key` or `DYNU_API_KEY` - Optional provider `base_url` override - Data sources: `dynu_domains`, `dynu_domain`, `dynu_dns_records` +- Resource: `dynu_dns_record` (CRUD + import using `domain_id/record_id`) Not implemented yet: -- Terraform resources (create/update/delete) -- Any write API operations +- Additional Terraform resources beyond `dynu_dns_record` +- Broader Dynu API coverage outside current DNS/domain scope diff --git a/examples/resources/dynu_dns_record/resource.tf b/examples/resources/dynu_dns_record/resource.tf new file mode 100644 index 0000000..07cbce9 --- /dev/null +++ b/examples/resources/dynu_dns_record/resource.tf @@ -0,0 +1,7 @@ +resource "dynu_dns_record" "txt" { + hostname = "api.example.com" + record_type = "TXT" + content = "managed-by-terraform" + ttl = 300 + state = true +} diff --git a/internal/dynuclient/client.go b/internal/dynuclient/client.go index 3f2094d..7688a62 100644 --- a/internal/dynuclient/client.go +++ b/internal/dynuclient/client.go @@ -1,6 +1,7 @@ package dynuclient import ( + "bytes" "context" "encoding/json" "errors" @@ -106,6 +107,26 @@ type DNSRecord struct { Host string `json:"host"` } +type CreateDNSRecordRequest struct { + NodeName string `json:"nodeName,omitempty"` + RecordType string `json:"recordType"` + Content string `json:"content"` + TTL int64 `json:"ttl,omitempty"` + State *bool `json:"state,omitempty"` + Group string `json:"group,omitempty"` + Host string `json:"host,omitempty"` +} + +type UpdateDNSRecordRequest struct { + NodeName string `json:"nodeName,omitempty"` + RecordType string `json:"recordType"` + Content string `json:"content"` + TTL int64 `json:"ttl,omitempty"` + State *bool `json:"state,omitempty"` + Group string `json:"group,omitempty"` + Host string `json:"host,omitempty"` +} + type listDomainsResponse struct { apiResponse Domains []Domain `json:"domains"` @@ -121,6 +142,11 @@ type listDNSRecordsResponse struct { DNSRecords []DNSRecord `json:"dnsRecords"` } +type getDNSRecordResponse struct { + apiResponse + DNSRecord +} + type getRootResponse struct { apiResponse ID int64 `json:"id"` @@ -131,7 +157,7 @@ type getRootResponse struct { func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) { var resp listDomainsResponse - if err := c.doGET(ctx, "/dns", &resp); err != nil { + if err := c.doRequest(ctx, http.MethodGet, "/dns", nil, &resp); err != nil { return nil, err } return resp.Domains, nil @@ -139,7 +165,7 @@ func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) { func (c *Client) GetDomainByID(ctx context.Context, domainID int64) (*Domain, error) { var resp getDomainResponse - if err := c.doGET(ctx, fmt.Sprintf("/dns/%d", domainID), &resp); err != nil { + if err := c.doRequest(ctx, http.MethodGet, fmt.Sprintf("/dns/%d", domainID), nil, &resp); err != nil { return nil, err } return &resp.Domain, nil @@ -147,7 +173,7 @@ func (c *Client) GetDomainByID(ctx context.Context, domainID int64) (*Domain, er func (c *Client) GetRootDomain(ctx context.Context, hostname string) (int64, string, error) { var resp getRootResponse - if err := c.doGET(ctx, fmt.Sprintf("/dns/getroot/%s", url.PathEscape(hostname)), &resp); err != nil { + if err := c.doRequest(ctx, http.MethodGet, fmt.Sprintf("/dns/getroot/%s", url.PathEscape(hostname)), nil, &resp); err != nil { return 0, "", err } @@ -160,21 +186,62 @@ func (c *Client) GetRootDomain(ctx context.Context, hostname string) (int64, str func (c *Client) ListDNSRecords(ctx context.Context, domainID int64) ([]DNSRecord, error) { var resp listDNSRecordsResponse - if err := c.doGET(ctx, fmt.Sprintf("/dns/%d/record", domainID), &resp); err != nil { + if err := c.doRequest(ctx, http.MethodGet, fmt.Sprintf("/dns/%d/record", domainID), nil, &resp); err != nil { return nil, err } return resp.DNSRecords, nil } -func (c *Client) doGET(ctx context.Context, path string, target any) error { +func (c *Client) GetDNSRecord(ctx context.Context, domainID int64, recordID int64) (*DNSRecord, error) { + var resp getDNSRecordResponse + if err := c.doRequest(ctx, http.MethodGet, fmt.Sprintf("/dns/%d/record/%d", domainID, recordID), nil, &resp); err != nil { + return nil, err + } + return &resp.DNSRecord, nil +} + +func (c *Client) CreateDNSRecord(ctx context.Context, domainID int64, req CreateDNSRecordRequest) (*DNSRecord, error) { + var resp getDNSRecordResponse + if err := c.doRequest(ctx, http.MethodPost, fmt.Sprintf("/dns/%d/record", domainID), req, &resp); err != nil { + return nil, err + } + return &resp.DNSRecord, nil +} + +func (c *Client) UpdateDNSRecord(ctx context.Context, domainID int64, recordID int64, req UpdateDNSRecordRequest) (*DNSRecord, error) { + var resp getDNSRecordResponse + if err := c.doRequest(ctx, http.MethodPut, fmt.Sprintf("/dns/%d/record/%d", domainID, recordID), req, &resp); err != nil { + return nil, err + } + return &resp.DNSRecord, nil +} + +func (c *Client) DeleteDNSRecord(ctx context.Context, domainID int64, recordID int64) error { + return c.doRequest(ctx, http.MethodDelete, fmt.Sprintf("/dns/%d/record/%d", domainID, recordID), nil, nil) +} + +func (c *Client) doRequest(ctx context.Context, method string, path string, requestBody any, target any) error { requestURL := c.baseURL + path - req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) + + var bodyReader io.Reader + if requestBody != nil { + body, err := json.Marshal(requestBody) + if err != nil { + return fmt.Errorf("failed to encode dynu API request: %w", err) + } + bodyReader = bytes.NewBuffer(body) + } + + req, err := http.NewRequestWithContext(ctx, method, requestURL, bodyReader) if err != nil { return err } req.Header.Set("Accept", "application/json") req.Header.Set("API-Key", c.apiKey) + if requestBody != nil { + req.Header.Set("Content-Type", "application/json") + } res, err := c.httpClient.Do(req) if err != nil { @@ -195,8 +262,10 @@ func (c *Client) doGET(ctx context.Context, path string, target any) error { return fmt.Errorf("dynu API returned status %d: %s", res.StatusCode, strings.TrimSpace(string(payload))) } - if err := json.Unmarshal(payload, target); err != nil { - return fmt.Errorf("failed to decode dynu API response: %w", err) + if len(bytes.TrimSpace(payload)) > 0 && target != nil { + if err := json.Unmarshal(payload, target); err != nil { + return fmt.Errorf("failed to decode dynu API response: %w", err) + } } if apiErr != nil { diff --git a/internal/dynuclient/client_test.go b/internal/dynuclient/client_test.go index 05e169a..e599e63 100644 --- a/internal/dynuclient/client_test.go +++ b/internal/dynuclient/client_test.go @@ -2,10 +2,10 @@ package dynuclient_test import ( "context" - "github.com/dynu/terraform-provider-dynu/internal/dynuclient" "strings" "testing" + "github.com/dynu/terraform-provider-dynu/internal/dynuclient" "github.com/dynu/terraform-provider-dynu/internal/testutil/fakedynu" ) @@ -23,7 +23,7 @@ func TestClientListDomainsSuccess(t *testing.T) { } } -func TestClientDoGetNon2xxStatus(t *testing.T) { +func TestClientDoRequestNon2xxStatus(t *testing.T) { fake := fakedynu.NewServer() defer fake.Close() fake.SetRawResponse("/dns", 401, `{"message":"nope"}`) @@ -35,7 +35,7 @@ func TestClientDoGetNon2xxStatus(t *testing.T) { } } -func TestClientDoGetAPIExceptionPayload(t *testing.T) { +func TestClientDoRequestAPIExceptionPayload(t *testing.T) { fake := fakedynu.NewServer() defer fake.Close() fake.SetAPIError("/dns", fakedynu.APIError{HTTPStatus: 400, StatusCode: 400, Type: "Validation Exception", Message: "bad hostname"}) @@ -47,7 +47,7 @@ func TestClientDoGetAPIExceptionPayload(t *testing.T) { } } -func TestClientDoGetMalformedJSON(t *testing.T) { +func TestClientDoRequestMalformedJSON(t *testing.T) { fake := fakedynu.NewServer() defer fake.Close() fake.SetRawResponse("/dns", 200, `{"statusCode":200,"domains":[`) @@ -82,3 +82,68 @@ func TestClientGetRootDomainEscapesHostname(t *testing.T) { t.Fatalf("expected hostname not found error, got %v", err) } } + +func TestClientDNSRecordCRUD(t *testing.T) { + fake := fakedynu.NewServer() + defer fake.Close() + + client := dynuclient.New("test-key", dynuclient.WithBaseURL(fake.BaseURL()), dynuclient.WithHTTPClient(fake.Client())) + state := true + created, err := client.CreateDNSRecord(context.Background(), 1001, dynuclient.CreateDNSRecordRequest{ + NodeName: "api", + RecordType: "TXT", + Content: "created", + TTL: 120, + State: &state, + Group: "integration", + }) + if err != nil { + t.Fatalf("CreateDNSRecord() error = %v", err) + } + if created.ID == 0 { + t.Fatal("expected created record id") + } + + got, err := client.GetDNSRecord(context.Background(), 1001, created.ID) + if err != nil { + t.Fatalf("GetDNSRecord() error = %v", err) + } + if got.Content != "created" { + t.Fatalf("expected content created, got %q", got.Content) + } + + updated, err := client.UpdateDNSRecord(context.Background(), 1001, created.ID, dynuclient.UpdateDNSRecordRequest{ + NodeName: "api", + RecordType: "TXT", + Content: "updated", + TTL: 180, + State: &state, + }) + if err != nil { + t.Fatalf("UpdateDNSRecord() error = %v", err) + } + if updated.Content != "updated" || updated.TTL != 180 { + t.Fatalf("unexpected update response: %#v", updated) + } + + if err := client.DeleteDNSRecord(context.Background(), 1001, created.ID); err != nil { + t.Fatalf("DeleteDNSRecord() error = %v", err) + } + + _, err = client.GetDNSRecord(context.Background(), 1001, created.ID) + if err == nil || !strings.Contains(err.Error(), "record not found") { + t.Fatalf("expected record not found after delete, got %v", err) + } +} + +func TestClientDNSRecordWriteAPIError(t *testing.T) { + fake := fakedynu.NewServer() + defer fake.Close() + fake.SetAPIError("/dns/1001/record", fakedynu.APIError{HTTPStatus: 400, StatusCode: 400, Type: "Validation Exception", Message: "recordType invalid"}) + + client := dynuclient.New("test-key", dynuclient.WithBaseURL(fake.BaseURL()), dynuclient.WithHTTPClient(fake.Client())) + _, err := client.CreateDNSRecord(context.Background(), 1001, dynuclient.CreateDNSRecordRequest{RecordType: "", Content: "x"}) + if err == nil || !strings.Contains(err.Error(), "Validation Exception") { + t.Fatalf("expected validation API error, got %v", err) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index b2981a2..4d6c83a 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -43,7 +43,7 @@ func (p *dynuProvider) Metadata(_ context.Context, _ provider.MetadataRequest, r func (p *dynuProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) { resp.Schema = schema.Schema{ - Description: "Terraform provider for Dynu DNS read-only data sources.", + Description: "Terraform provider for Dynu DNS domains, records, and data sources.", Attributes: map[string]schema.Attribute{ "api_key": schema.StringAttribute{ Optional: true, @@ -78,7 +78,7 @@ func (p *dynuProvider) Configure(ctx context.Context, req provider.ConfigureRequ providerData := &providerData{client: newDynuClient(apiKey, data.BaseURL)} resp.DataSourceData = providerData - resp.ResourceData = nil + resp.ResourceData = providerData } func resolveAPIKey(configValue types.String, envValue string) string { @@ -97,7 +97,9 @@ func (p *dynuProvider) DataSources(_ context.Context) []func() datasource.DataSo } func (p *dynuProvider) Resources(_ context.Context) []func() resource.Resource { - return nil + return []func() resource.Resource{ + NewDNSRecordResource, + } } func newDynuClient(apiKey string, baseURL types.String) *dynuclient.Client { diff --git a/internal/provider/resource_dns_record.go b/internal/provider/resource_dns_record.go new file mode 100644 index 0000000..1f9d3ac --- /dev/null +++ b/internal/provider/resource_dns_record.go @@ -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()) +} diff --git a/internal/provider/resource_dns_record_integration_test.go b/internal/provider/resource_dns_record_integration_test.go new file mode 100644 index 0000000..e3d049d --- /dev/null +++ b/internal/provider/resource_dns_record_integration_test.go @@ -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) + } +} diff --git a/internal/provider/resource_dns_record_test.go b/internal/provider/resource_dns_record_test.go new file mode 100644 index 0000000..9aa2f64 --- /dev/null +++ b/internal/provider/resource_dns_record_test.go @@ -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") + } +} diff --git a/internal/testutil/fakedynu/server.go b/internal/testutil/fakedynu/server.go index 70ed499..dc1a537 100644 --- a/internal/testutil/fakedynu/server.go +++ b/internal/testutil/fakedynu/server.go @@ -3,11 +3,13 @@ package fakedynu import ( "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "strconv" "strings" "sync" + "time" "github.com/dynu/terraform-provider-dynu/internal/dynuclient" ) @@ -39,6 +41,7 @@ type Server struct { fixture Fixture errors map[string]APIError rawPayload map[string]rawResponse + nextIDs map[int64]int64 } type rawResponse struct { @@ -46,10 +49,21 @@ type rawResponse struct { body string } +type dnsRecordUpsertRequest struct { + NodeName string `json:"nodeName"` + RecordType string `json:"recordType"` + Content string `json:"content"` + TTL int64 `json:"ttl"` + State *bool `json:"state"` + Group string `json:"group"` + Host string `json:"host"` +} + func NewServer() *Server { s := &Server{ errors: map[string]APIError{}, rawPayload: map[string]rawResponse{}, + nextIDs: map[int64]int64{}, fixture: Fixture{ Domains: []dynuclient.Domain{ {ID: 2002, Name: "z.example.com", UnicodeName: "z.example.com", TTL: 60, CreatedOn: "2024-01-03T00:00:00", UpdatedOn: "2024-01-04T00:00:00"}, @@ -68,6 +82,7 @@ func NewServer() *Server { }, } + s.reseedNextIDs() s.Server = httptest.NewServer(http.HandlerFunc(s.serveHTTP)) return s } @@ -80,6 +95,7 @@ func (s *Server) SetFixture(fixture Fixture) { s.mu.Lock() defer s.mu.Unlock() s.fixture = fixture + s.reseedNextIDs() } func (s *Server) SetAPIError(path string, apiErr APIError) { @@ -94,14 +110,22 @@ func (s *Server) SetRawResponse(path string, httpStatus int, body string) { s.rawPayload[path] = rawResponse{httpStatus: httpStatus, body: body} } -func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - w.WriteHeader(http.StatusMethodNotAllowed) - return +func (s *Server) DeleteRecord(domainID int64, recordID int64) { + s.mu.Lock() + defer s.mu.Unlock() + records := s.fixture.RecordsByDomain[domainID] + updated := make([]dynuclient.DNSRecord, 0, len(records)) + for _, record := range records { + if record.ID != recordID { + updated = append(updated, record) + } } + s.fixture.RecordsByDomain[domainID] = updated +} - s.mu.RLock() - defer s.mu.RUnlock() +func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() if raw, ok := s.rawPayload[r.URL.Path]; ok { w.Header().Set("Content-Type", "application/json") @@ -116,10 +140,10 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) { } switch { - case r.URL.Path == "/dns": + case r.Method == http.MethodGet && r.URL.Path == "/dns": s.writeJSON(w, http.StatusOK, map[string]any{"statusCode": 200, "domains": s.fixture.Domains}) return - case strings.HasPrefix(r.URL.Path, "/dns/getroot/"): + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/dns/getroot/"): hostname := strings.TrimPrefix(r.URL.Path, "/dns/getroot/") root, ok := s.fixture.RootsByHostname[hostname] if !ok { @@ -134,37 +158,266 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) { "node": root.Node, }) return - case strings.HasSuffix(r.URL.Path, "/record"): - domainID, err := domainIDFromPath(strings.TrimSuffix(r.URL.Path, "/record")) - if err != nil { - s.writeAPIError(w, APIError{HTTPStatus: http.StatusBadRequest, StatusCode: 400, Type: "Validation Exception", Message: err.Error()}) - return - } - records, ok := s.fixture.RecordsByDomain[domainID] - if !ok { - records = []dynuclient.DNSRecord{} - } - s.writeJSON(w, http.StatusOK, map[string]any{"statusCode": 200, "dnsRecords": records}) + case strings.HasPrefix(r.URL.Path, "/dns/"): + s.serveDNSPath(w, r) return default: - domainID, err := domainIDFromPath(r.URL.Path) - if err != nil { - s.writeAPIError(w, APIError{HTTPStatus: http.StatusNotFound, StatusCode: 404, Type: "Not Found", Message: "endpoint not found"}) + s.writeAPIError(w, APIError{HTTPStatus: http.StatusNotFound, StatusCode: 404, Type: "Not Found", Message: "endpoint not found"}) + } +} + +func (s *Server) serveDNSPath(w http.ResponseWriter, r *http.Request) { + trimmed := strings.TrimPrefix(r.URL.Path, "/dns/") + segments := strings.Split(trimmed, "/") + if len(segments) == 1 { + s.serveDomainByID(w, r.Method, segments[0]) + return + } + + if len(segments) >= 2 && segments[1] == "record" { + s.serveRecordRoutes(w, r, segments) + return + } + + s.writeAPIError(w, APIError{HTTPStatus: http.StatusNotFound, StatusCode: 404, Type: "Not Found", Message: "endpoint not found"}) +} + +func (s *Server) serveDomainByID(w http.ResponseWriter, method string, rawDomainID string) { + if method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + domainID, err := strconv.ParseInt(rawDomainID, 10, 64) + if err != nil { + s.writeAPIError(w, APIError{HTTPStatus: http.StatusBadRequest, StatusCode: 400, Type: "Validation Exception", Message: "invalid domain id: " + rawDomainID}) + return + } + for _, domain := range s.fixture.Domains { + if domain.ID == domainID { + payload := map[string]any{"statusCode": 200} + b, _ := json.Marshal(domain) + _ = json.Unmarshal(b, &payload) + s.writeJSON(w, http.StatusOK, payload) return } - for _, domain := range s.fixture.Domains { - if domain.ID == domainID { - payload := map[string]any{"statusCode": 200} - b, _ := json.Marshal(domain) - _ = json.Unmarshal(b, &payload) - s.writeJSON(w, http.StatusOK, payload) - return + } + s.writeAPIError(w, APIError{HTTPStatus: http.StatusNotFound, StatusCode: 404, Type: "Not Found", Message: "domain not found"}) +} + +func (s *Server) serveRecordRoutes(w http.ResponseWriter, r *http.Request, segments []string) { + domainID, err := strconv.ParseInt(segments[0], 10, 64) + if err != nil { + s.writeAPIError(w, APIError{HTTPStatus: http.StatusBadRequest, StatusCode: 400, Type: "Validation Exception", Message: "invalid domain id: " + segments[0]}) + return + } + + if len(segments) == 2 { + switch r.Method { + case http.MethodGet: + records := s.fixture.RecordsByDomain[domainID] + if records == nil { + records = []dynuclient.DNSRecord{} + } + s.writeJSON(w, http.StatusOK, map[string]any{"statusCode": 200, "dnsRecords": records}) + return + case http.MethodPost: + s.handleCreateRecord(w, r, domainID) + return + default: + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + } + + if len(segments) == 3 { + recordID, err := strconv.ParseInt(segments[2], 10, 64) + if err != nil { + s.writeAPIError(w, APIError{HTTPStatus: http.StatusBadRequest, StatusCode: 400, Type: "Validation Exception", Message: "invalid record id: " + segments[2]}) + return + } + switch r.Method { + case http.MethodGet: + s.handleGetRecord(w, domainID, recordID) + return + case http.MethodPut: + s.handleUpdateRecord(w, r, domainID, recordID) + return + case http.MethodDelete: + s.handleDeleteRecord(w, domainID, recordID) + return + default: + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + } + + s.writeAPIError(w, APIError{HTTPStatus: http.StatusNotFound, StatusCode: 404, Type: "Not Found", Message: "endpoint not found"}) +} + +func (s *Server) handleGetRecord(w http.ResponseWriter, domainID int64, recordID int64) { + record, _, ok := s.findRecord(domainID, recordID) + if !ok { + s.writeAPIError(w, APIError{HTTPStatus: http.StatusNotFound, StatusCode: 404, Type: "Not Found", Message: "record not found"}) + return + } + s.writeRecord(w, record) +} + +func (s *Server) handleCreateRecord(w http.ResponseWriter, r *http.Request, domainID int64) { + req, ok := s.decodeUpsertRequest(w, r) + if !ok { + return + } + if req.RecordType == "" || req.Content == "" { + s.writeAPIError(w, APIError{HTTPStatus: http.StatusBadRequest, StatusCode: 400, Type: "Validation Exception", Message: "recordType and content are required"}) + return + } + + domainName := s.domainName(domainID) + if domainName == "" { + s.writeAPIError(w, APIError{HTTPStatus: http.StatusNotFound, StatusCode: 404, Type: "Not Found", Message: "domain not found"}) + return + } + state := true + if req.State != nil { + state = *req.State + } + ttl := req.TTL + if ttl <= 0 { + ttl = 90 + } + record := dynuclient.DNSRecord{ + ID: s.nextRecordID(domainID), + DomainID: domainID, + DomainName: domainName, + NodeName: req.NodeName, + Hostname: buildHostname(req.NodeName, domainName), + RecordType: req.RecordType, + State: state, + TTL: ttl, + Content: req.Content, + UpdatedOn: time.Now().UTC().Format(time.RFC3339), + Group: req.Group, + Host: req.Host, + } + + s.fixture.RecordsByDomain[domainID] = append(s.fixture.RecordsByDomain[domainID], record) + s.writeRecord(w, record) +} + +func (s *Server) handleUpdateRecord(w http.ResponseWriter, r *http.Request, domainID int64, recordID int64) { + req, ok := s.decodeUpsertRequest(w, r) + if !ok { + return + } + record, idx, found := s.findRecord(domainID, recordID) + if !found { + s.writeAPIError(w, APIError{HTTPStatus: http.StatusNotFound, StatusCode: 404, Type: "Not Found", Message: "record not found"}) + return + } + + record.NodeName = req.NodeName + record.RecordType = req.RecordType + record.Content = req.Content + if req.TTL > 0 { + record.TTL = req.TTL + } + if req.State != nil { + record.State = *req.State + } + record.Group = req.Group + record.Host = req.Host + record.Hostname = buildHostname(req.NodeName, record.DomainName) + record.UpdatedOn = time.Now().UTC().Format(time.RFC3339) + + s.fixture.RecordsByDomain[domainID][idx] = record + s.writeRecord(w, record) +} + +func (s *Server) handleDeleteRecord(w http.ResponseWriter, domainID int64, recordID int64) { + _, idx, ok := s.findRecord(domainID, recordID) + if !ok { + s.writeAPIError(w, APIError{HTTPStatus: http.StatusNotFound, StatusCode: 404, Type: "Not Found", Message: "record not found"}) + return + } + + records := s.fixture.RecordsByDomain[domainID] + s.fixture.RecordsByDomain[domainID] = append(records[:idx], records[idx+1:]...) + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) decodeUpsertRequest(w http.ResponseWriter, r *http.Request) (dnsRecordUpsertRequest, bool) { + payload, err := io.ReadAll(r.Body) + if err != nil { + s.writeAPIError(w, APIError{HTTPStatus: http.StatusBadRequest, StatusCode: 400, Type: "Validation Exception", Message: "unable to read request body"}) + return dnsRecordUpsertRequest{}, false + } + defer r.Body.Close() + + var req dnsRecordUpsertRequest + if err := json.Unmarshal(payload, &req); err != nil { + s.writeAPIError(w, APIError{HTTPStatus: http.StatusBadRequest, StatusCode: 400, Type: "Validation Exception", Message: "invalid json payload"}) + return dnsRecordUpsertRequest{}, false + } + return req, true +} + +func (s *Server) findRecord(domainID int64, recordID int64) (dynuclient.DNSRecord, int, bool) { + records := s.fixture.RecordsByDomain[domainID] + for idx, record := range records { + if record.ID == recordID { + return record, idx, true + } + } + return dynuclient.DNSRecord{}, -1, false +} + +func (s *Server) writeRecord(w http.ResponseWriter, record dynuclient.DNSRecord) { + payload := map[string]any{"statusCode": 200} + b, _ := json.Marshal(record) + _ = json.Unmarshal(b, &payload) + s.writeJSON(w, http.StatusOK, payload) +} + +func (s *Server) domainName(domainID int64) string { + for _, domain := range s.fixture.Domains { + if domain.ID == domainID { + return domain.Name + } + } + return "" +} + +func (s *Server) nextRecordID(domainID int64) int64 { + nextID := s.nextIDs[domainID] + if nextID == 0 { + nextID = 1 + } + s.nextIDs[domainID] = nextID + 1 + return nextID +} + +func (s *Server) reseedNextIDs() { + s.nextIDs = map[int64]int64{} + for domainID, records := range s.fixture.RecordsByDomain { + maxID := int64(0) + for _, record := range records { + if record.ID > maxID { + maxID = record.ID } } - s.writeAPIError(w, APIError{HTTPStatus: http.StatusNotFound, StatusCode: 404, Type: "Not Found", Message: "domain not found"}) + s.nextIDs[domainID] = maxID + 1 } } +func buildHostname(nodeName string, domainName string) string { + nodeName = strings.TrimSpace(nodeName) + if nodeName == "" || nodeName == "@" { + return domainName + } + return nodeName + "." + domainName +} + func domainIDFromPath(path string) (int64, error) { trimmed := strings.TrimPrefix(path, "/dns/") if trimmed == path { From 5db7d5d8a88c070a83e8cfe11d172be359379e9e Mon Sep 17 00:00:00 2001 From: beatz174-bit Date: Thu, 23 Apr 2026 14:00:11 +1000 Subject: [PATCH 2/2] Fix gofmt import indentation in dns record resource --- internal/provider/resource_dns_record.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/provider/resource_dns_record.go b/internal/provider/resource_dns_record.go index 1f9d3ac..759b74a 100644 --- a/internal/provider/resource_dns_record.go +++ b/internal/provider/resource_dns_record.go @@ -9,7 +9,7 @@ import ( "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" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types"