From 6cc1b206dc1007c5bea80e09aac6e52089a68515 Mon Sep 17 00:00:00 2001 From: beatz174-bit Date: Tue, 21 Apr 2026 14:39:36 +1000 Subject: [PATCH] Harden read-only provider with tests, docs, and CI --- .github/workflows/ci.yml | 20 +++ README.md | 117 ++++++++++-------- docs/data-sources/dns_records.md | 39 ++++++ docs/data-sources/domain.md | 25 ++++ docs/data-sources/domains.md | 34 +++++ docs/index.md | 32 +++++ .../dynu_dns_records/data-source.tf | 10 ++ .../data-sources/dynu_domain/data-source.tf | 10 ++ .../data-sources/dynu_domains/data-source.tf | 10 ++ examples/provider/provider.tf | 10 +- go.mod | 28 ++--- go.sum | 82 ++++++------ internal/dynuclient/client.go | 25 +++- internal/dynuclient/client_test.go | 78 ++++++++++-- internal/provider/data_source_dns_records.go | 48 ++++--- internal/provider/data_source_domain.go | 59 ++------- internal/provider/data_source_domains.go | 40 +++--- internal/provider/mappers.go | 77 +++++++++++- internal/provider/mappers_test.go | 44 +++++++ internal/provider/provider.go | 37 ++++-- internal/provider/provider_acc_test.go | 77 ++++++++++-- internal/provider/provider_test.go | 30 +++++ scripts/check.sh | 19 ++- scripts/testacc.sh | 6 +- 24 files changed, 711 insertions(+), 246 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 docs/data-sources/dns_records.md create mode 100644 docs/data-sources/domain.md create mode 100644 docs/data-sources/domains.md create mode 100644 docs/index.md create mode 100644 internal/provider/mappers_test.go create mode 100644 internal/provider/provider_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4fc32be --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: ci + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Verify formatting and run quality gate + run: ./scripts/check.sh diff --git a/README.md b/README.md index 22742d4..4181712 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,38 @@ # terraform-provider-dynu -A standalone Terraform provider for [Dynu](https://www.dynu.com/) DNS data. +A standalone Terraform provider for Dynu DNS. -> Current phase: **read-only**. This provider supports provider configuration and data sources only. +> Status: **read-only milestone**. This provider currently implements provider configuration and data sources only. ## Feature scope Implemented: -- Provider authentication via API key -- Environment variable support (`DYNU_API_KEY`) -- Read-only data sources: +- Provider authentication using `api_key` or `DYNU_API_KEY` +- Data sources: - `dynu_domains` - `dynu_domain` - `dynu_dns_records` -Not implemented in this phase: -- Terraform resources -- Any create/update/delete operations +Not implemented yet: +- Terraform resources (no create/update/delete) +- Any write API operations + +## Provider source and module path + +- Terraform provider source address: `dynu/dynu` +- Go module path: `github.com/dynu/terraform-provider-dynu` + +The repository can be hosted elsewhere during development, but module and provider source naming are kept aligned with planned public registry publishing. ## Requirements -- [Terraform](https://developer.hashicorp.com/terraform/downloads) `>= 1.5` -- [Go](https://go.dev/dl/) `>= 1.23` (for building and testing) +- Terraform `>= 1.5` +- Go `>= 1.23` - Dynu API key -## Provider configuration +## Authentication + +Option 1: Terraform configuration. ```hcl provider "dynu" { @@ -37,76 +45,77 @@ variable "dynu_api_key" { } ``` -You can omit `api_key` in Terraform configuration and set the environment variable instead: +Option 2: Environment variable. ```bash export DYNU_API_KEY="your-dynu-api-key" ``` -## Data source usage +## Data source examples -### dynu_domains +See the `examples/` directory: +- `examples/provider/provider.tf` +- `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` -```hcl -data "dynu_domains" "all" {} -``` +## Developer workflow -### dynu_domain +- `./scripts/setup-dev.sh` - verify required local tools +- `./scripts/check.sh` - formatting, vet, and unit tests +- `./scripts/testacc.sh` - acceptance tests only -```hcl -data "dynu_domain" "selected" { - hostname = "www.example.com" -} -``` - -### dynu_dns_records - -```hcl -data "dynu_dns_records" "records" { - hostname = "www.example.com" -} -``` - -## Build +### Build ```bash go build ./... ``` -## Test - -Run formatting and unit tests: - -```bash -./scripts/check.sh -``` - -Or run unit tests directly: +### Unit tests ```bash go test ./... ``` -## Acceptance tests +### Acceptance tests -Acceptance tests are opt-in and require live Dynu credentials. +Acceptance tests are read-only and opt-in. + +Required environment variables: +- `TF_ACC=1` +- `DYNU_API_KEY` + +Optional: +- `DYNU_DOMAIN` (required for domain-specific acceptance tests such as `dynu_domain` and `dynu_dns_records`) + +Run: ```bash -TF_ACC=1 DYNU_API_KEY="your-dynu-api-key" ./scripts/testacc.sh +TF_ACC=1 DYNU_API_KEY="your-dynu-api-key" DYNU_DOMAIN="www.example.com" ./scripts/testacc.sh ``` -Optional environment variable: -- `DYNU_DOMAIN` (for future domain-specific acceptance test cases) +If `DYNU_DOMAIN` is omitted, domain-specific tests skip cleanly. -## Developer workflow +## CI -- `./scripts/setup-dev.sh` – validates required local tools -- `./scripts/check.sh` – runs formatting and unit checks -- `./scripts/testacc.sh` – runs acceptance tests +GitHub Actions CI runs on push and pull requests and executes: +- gofmt verification +- `go vet ./...` +- `go test ./...` -Repository-local Codex helpers are also available under `codex/` for agent-oriented workflows. +Acceptance tests are intentionally excluded from default CI. + +## Documentation + +Registry-style markdown docs are stored in `docs/`. ## Limitations -- Read-only provider phase only -- No writable Terraform resources yet +- Dynu timestamps are currently exposed as strings exactly as returned by Dynu API. +- Data returned from Dynu is sorted in provider state for Terraform stability. +- Read-only operations only. + +## Roadmap + +Next planned milestone after this quality-hardening release: +- first writable resource (`dynu_dns_record`) with careful CRUD behavior and acceptance coverage. diff --git a/docs/data-sources/dns_records.md b/docs/data-sources/dns_records.md new file mode 100644 index 0000000..f914590 --- /dev/null +++ b/docs/data-sources/dns_records.md @@ -0,0 +1,39 @@ +--- +page_title: "dynu_dns_records Data Source" +description: |- + Lists DNS records for the Dynu root domain resolved from a hostname. +--- + +# dynu_dns_records Data Source + +## Example Usage + +```terraform +data "dynu_dns_records" "records" { + hostname = "www.example.com" +} +``` + +## Schema + +### Required + +- `hostname` (String) Fully-qualified hostname. + +### Read-Only + +- `domain_id` (Number) +- `domain_name` (String) +- `records` (List of Object) + - `id` (Number) + - `domain_id` (Number) + - `domain_name` (String) + - `node_name` (String) + - `hostname` (String) + - `record_type` (String) + - `ttl` (Number) + - `state` (Boolean) + - `content` (String) + - `updated_on` (String) + - `group` (String) + - `host` (String) diff --git a/docs/data-sources/domain.md b/docs/data-sources/domain.md new file mode 100644 index 0000000..1701c75 --- /dev/null +++ b/docs/data-sources/domain.md @@ -0,0 +1,25 @@ +--- +page_title: "dynu_domain Data Source" +description: |- + Resolves a hostname to its Dynu root domain and returns domain details. +--- + +# dynu_domain Data Source + +## Example Usage + +```terraform +data "dynu_domain" "selected" { + hostname = "www.example.com" +} +``` + +## Schema + +### Required + +- `hostname` (String) Fully-qualified hostname. + +### Read-Only + +- `domain` (Object) Same fields returned by `dynu_domains.domains[*]`. diff --git a/docs/data-sources/domains.md b/docs/data-sources/domains.md new file mode 100644 index 0000000..ec9c763 --- /dev/null +++ b/docs/data-sources/domains.md @@ -0,0 +1,34 @@ +--- +page_title: "dynu_domains Data Source" +description: |- + Lists domains visible to the configured Dynu API key. +--- + +# dynu_domains Data Source + +## Example Usage + +```terraform +data "dynu_domains" "all" {} +``` + +## Read-Only Attributes + +- `domains` (List of Object) + - `id` (Number) + - `name` (String) + - `unicode_name` (String) + - `token` (String, Sensitive) + - `state` (String) + - `group` (String) + - `ipv4_address` (String) + - `ipv6_address` (String) + - `ttl` (Number) + - `ipv4` (Boolean) + - `ipv6` (Boolean) + - `ipv4_wildcard_alias` (Boolean) + - `ipv6_wildcard_alias` (Boolean) + - `allow_zone_transfer` (Boolean) + - `dnssec` (Boolean) + - `created_on` (String) + - `updated_on` (String) diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..a2c97a3 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,32 @@ +--- +page_title: "dynu Provider" +description: |- + Terraform provider for Dynu DNS read-only data sources. +--- + +# dynu Provider + +The `dynu` provider lets Terraform read Dynu DNS domain and record data. + +## Example Usage + +```terraform +terraform { + required_providers { + dynu = { + source = "dynu/dynu" + } + } +} + +provider "dynu" { + api_key = var.dynu_api_key +} +``` + +## Schema + +### Optional + +- `api_key` (String, Sensitive) Dynu API key. If omitted, `DYNU_API_KEY` is used. +- `base_url` (String) Override API base URL. Primarily intended for automated tests. diff --git a/examples/data-sources/dynu_dns_records/data-source.tf b/examples/data-sources/dynu_dns_records/data-source.tf index 3df6593..7c06b29 100644 --- a/examples/data-sources/dynu_dns_records/data-source.tf +++ b/examples/data-sources/dynu_dns_records/data-source.tf @@ -1,3 +1,13 @@ +terraform { + required_providers { + dynu = { + source = "dynu/dynu" + } + } +} + +provider "dynu" {} + data "dynu_dns_records" "records" { hostname = "www.example.com" } diff --git a/examples/data-sources/dynu_domain/data-source.tf b/examples/data-sources/dynu_domain/data-source.tf index aff9b93..6e1d468 100644 --- a/examples/data-sources/dynu_domain/data-source.tf +++ b/examples/data-sources/dynu_domain/data-source.tf @@ -1,3 +1,13 @@ +terraform { + required_providers { + dynu = { + source = "dynu/dynu" + } + } +} + +provider "dynu" {} + data "dynu_domain" "selected" { hostname = "www.example.com" } diff --git a/examples/data-sources/dynu_domains/data-source.tf b/examples/data-sources/dynu_domains/data-source.tf index 9c6c1f2..97d4811 100644 --- a/examples/data-sources/dynu_domains/data-source.tf +++ b/examples/data-sources/dynu_domains/data-source.tf @@ -1,3 +1,13 @@ +terraform { + required_providers { + dynu = { + source = "dynu/dynu" + } + } +} + +provider "dynu" {} + data "dynu_domains" "all" {} output "domains" { diff --git a/examples/provider/provider.tf b/examples/provider/provider.tf index 3462b74..b601ea0 100644 --- a/examples/provider/provider.tf +++ b/examples/provider/provider.tf @@ -1,5 +1,13 @@ +terraform { + required_providers { + dynu = { + source = "dynu/dynu" + } + } +} + provider "dynu" { - # api_key can be omitted when DYNU_API_KEY is set + # api_key can be omitted when DYNU_API_KEY is set. api_key = var.dynu_api_key } diff --git a/go.mod b/go.mod index eb52fc3..979d11d 100644 --- a/go.mod +++ b/go.mod @@ -10,26 +10,26 @@ require ( ) require ( - github.com/fatih/color v1.17.0 // indirect + github.com/fatih/color v1.13.0 // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/hashicorp/go-hclog v1.6.3 // indirect - github.com/hashicorp/go-plugin v1.6.3 // indirect + github.com/hashicorp/go-hclog v1.5.0 // indirect + github.com/hashicorp/go-plugin v1.6.2 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect - github.com/hashicorp/terraform-plugin-go v0.27.0 // indirect + github.com/hashicorp/terraform-plugin-go v0.26.0 // indirect github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect - github.com/hashicorp/terraform-registry-address v0.2.5 // indirect + github.com/hashicorp/terraform-registry-address v0.2.4 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect github.com/hashicorp/yamux v0.1.1 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect - github.com/oklog/run v1.1.0 // indirect + github.com/oklog/run v1.0.0 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - golang.org/x/net v0.41.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.26.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect - google.golang.org/grpc v1.72.2 // indirect - google.golang.org/protobuf v1.36.6 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.21.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 // indirect + google.golang.org/grpc v1.69.4 // indirect + google.golang.org/protobuf v1.36.3 // indirect ) diff --git a/go.sum b/go.sum index adc37fc..2049045 100644 --- a/go.sum +++ b/go.sum @@ -3,35 +3,34 @@ github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= -github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= -github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= -github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= -github.com/hashicorp/go-plugin v1.6.3 h1:xgHB+ZUSYeuJi96WtxEjzi23uh7YQpznjGh0U0UUrwg= -github.com/hashicorp/go-plugin v1.6.3/go.mod h1:MRobyh+Wc/nYy1V4KAXUiYfzxoYhs7V1mlH1Z7iY2h0= +github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c= +github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.6.2 h1:zdGAEd0V1lCaU0u+MxWQhtSDQmahpkwOun8U8EiRVog= +github.com/hashicorp/go-plugin v1.6.2/go.mod h1:CkgLQ5CZqNmdL9U9JzM532t8ZiYQ35+pj3b1FD37R0Q= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/terraform-plugin-framework v1.14.1 h1:jaT1yvU/kEKEsxnbrn4ZHlgcxyIfjvZ41BLdlLk52fY= github.com/hashicorp/terraform-plugin-framework v1.14.1/go.mod h1:xNUKmvTs6ldbwTuId5euAtg37dTxuyj3LHS3uj7BHQ4= github.com/hashicorp/terraform-plugin-framework-validators v0.17.0 h1:0uYQcqqgW3BMyyve07WJgpKorXST3zkpzvrOnf3mpbg= github.com/hashicorp/terraform-plugin-framework-validators v0.17.0/go.mod h1:VwdfgE/5Zxm43flraNa0VjcvKQOGVrcO4X8peIri0T0= -github.com/hashicorp/terraform-plugin-go v0.27.0 h1:ujykws/fWIdsi6oTUT5Or4ukvEan4aN9lY+LOxVP8EE= -github.com/hashicorp/terraform-plugin-go v0.27.0/go.mod h1:FDa2Bb3uumkTGSkTFpWSOwWJDwA7bf3vdP3ltLDTH6o= +github.com/hashicorp/terraform-plugin-go v0.26.0 h1:cuIzCv4qwigug3OS7iKhpGAbZTiypAfFQmw8aE65O2M= +github.com/hashicorp/terraform-plugin-go v0.26.0/go.mod h1:+CXjuLDiFgqR+GcrM5a2E2Kal5t5q2jb0E3D57tTdNY= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= -github.com/hashicorp/terraform-registry-address v0.2.5 h1:2GTftHqmUhVOeuu9CW3kwDkRe4pcBDq0uuK5VJngU1M= -github.com/hashicorp/terraform-registry-address v0.2.5/go.mod h1:PpzXWINwB5kuVS5CA7m1+eO2f1jKb5ZDIxrOPfpnGkg= +github.com/hashicorp/terraform-registry-address v0.2.4 h1:JXu/zHB2Ymg/TGVCRu10XqNa4Sh2bWcqCNyKWjnCPJA= +github.com/hashicorp/terraform-registry-address v0.2.4/go.mod h1:tUNYTVyCtU4OIGXXMDp7WNcJ+0W1B4nmstVDgHMjfAU= github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= @@ -39,18 +38,16 @@ github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbg github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= -github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= -github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -61,37 +58,34 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= -go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= -go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= -go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= -go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= -go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= -go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= -go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= -go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= -go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= +go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= +go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= +go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= +go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= +go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= +go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= +go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= +go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= +go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ= -google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8= -google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 h1:X58yt85/IXCx0Y3ZwN6sEIKZzQtDEYaBWrDvErdXrRE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= +google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A= +google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= +google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= +google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/dynuclient/client.go b/internal/dynuclient/client.go index 705a34a..821506d 100644 --- a/internal/dynuclient/client.go +++ b/internal/dynuclient/client.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "time" ) @@ -136,7 +137,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", hostname), &resp); err != nil { + if err := c.doGET(ctx, fmt.Sprintf("/dns/getroot/%s", url.PathEscape(hostname)), &resp); err != nil { return 0, "", err } @@ -156,8 +157,8 @@ func (c *Client) ListDNSRecords(ctx context.Context, domainID int64) ([]DNSRecor } func (c *Client) doGET(ctx context.Context, path string, target any) error { - url := c.baseURL + path - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + requestURL := c.baseURL + path + req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) if err != nil { return err } @@ -176,7 +177,11 @@ func (c *Client) doGET(ctx context.Context, path string, target any) error { return err } + apiErr := parseAPIException(payload) if res.StatusCode < 200 || res.StatusCode >= 300 { + if apiErr != nil { + return apiErr + } return fmt.Errorf("dynu API returned status %d: %s", res.StatusCode, strings.TrimSpace(string(payload))) } @@ -184,10 +189,18 @@ func (c *Client) doGET(ctx context.Context, path string, target any) error { return fmt.Errorf("failed to decode dynu API response: %w", err) } - apiResult := apiResponse{} - if err := json.Unmarshal(payload, &apiResult); err == nil && apiResult.Exception != nil { - return fmt.Errorf("dynu API error %d (%s): %s", apiResult.Exception.StatusCode, apiResult.Exception.Type, apiResult.Exception.Message) + if apiErr != nil { + return apiErr } return nil } + +func parseAPIException(payload []byte) error { + apiResult := apiResponse{} + if err := json.Unmarshal(payload, &apiResult); err != nil || apiResult.Exception == nil { + return nil + } + + return fmt.Errorf("dynu API error %d (%s): %s", apiResult.Exception.StatusCode, apiResult.Exception.Type, apiResult.Exception.Message) +} diff --git a/internal/dynuclient/client_test.go b/internal/dynuclient/client_test.go index a7d004b..25cef6c 100644 --- a/internal/dynuclient/client_test.go +++ b/internal/dynuclient/client_test.go @@ -4,10 +4,11 @@ import ( "context" "net/http" "net/http/httptest" + "strings" "testing" ) -func TestListDomains(t *testing.T) { +func TestClientListDomainsSuccess(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/dns" { t.Fatalf("unexpected path: %s", r.URL.Path) @@ -16,7 +17,7 @@ func TestListDomains(t *testing.T) { t.Fatalf("unexpected api key header: %s", got) } w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"statusCode":200,"domains":[{"id":1,"name":"example.com","state":"Complete"}]}`)) + _, _ = w.Write([]byte(`{"statusCode":200,"domains":[{"id":2,"name":"z.example.com"},{"id":1,"name":"a.example.com"}]}`)) })) defer ts.Close() @@ -25,24 +26,79 @@ func TestListDomains(t *testing.T) { if err != nil { t.Fatalf("ListDomains() error = %v", err) } - if len(domains) != 1 { - t.Fatalf("expected 1 domain, got %d", len(domains)) - } - if domains[0].Name != "example.com" { - t.Fatalf("unexpected domain name %q", domains[0].Name) + if len(domains) != 2 { + t.Fatalf("expected 2 domains, got %d", len(domains)) } } -func TestDoGetErrorResponse(t *testing.T) { +func TestClientDoGetNon2xxStatus(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte(`{"statusCode":401,"exception":{"statusCode":401,"type":"Authentication Exception","message":"invalid"}}`)) + _, _ = w.Write([]byte(`{"message":"nope"}`)) })) defer ts.Close() client := New("test-key", WithBaseURL(ts.URL), WithHTTPClient(ts.Client())) _, err := client.ListDomains(context.Background()) - if err == nil { - t.Fatal("expected error, got nil") + if err == nil || !strings.Contains(err.Error(), "status 401") { + t.Fatalf("expected status error, got %v", err) + } +} + +func TestClientDoGetAPIExceptionPayload(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"statusCode":200,"exception":{"statusCode":400,"type":"Validation Exception","message":"bad hostname"}}`)) + })) + defer ts.Close() + + client := New("test-key", WithBaseURL(ts.URL), WithHTTPClient(ts.Client())) + _, err := client.ListDomains(context.Background()) + if err == nil || !strings.Contains(err.Error(), "Validation Exception") { + t.Fatalf("expected API exception error, got %v", err) + } +} + +func TestClientDoGetMalformedJSON(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"statusCode":200,"domains":[`)) + })) + defer ts.Close() + + client := New("test-key", WithBaseURL(ts.URL), WithHTTPClient(ts.Client())) + _, err := client.ListDomains(context.Background()) + if err == nil || !strings.Contains(err.Error(), "failed to decode dynu API response") { + t.Fatalf("expected decode error, got %v", err) + } +} + +func TestClientGetRootDomainIncompleteResponse(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"statusCode":200,"id":0,"domainName":""}`)) + })) + defer ts.Close() + + client := New("test-key", WithBaseURL(ts.URL), WithHTTPClient(ts.Client())) + _, _, err := client.GetRootDomain(context.Background(), "www.example.com") + if err == nil || !strings.Contains(err.Error(), "incomplete root domain response") { + t.Fatalf("expected incomplete response error, got %v", err) + } +} + +func TestClientGetRootDomainEscapesHostname(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.RequestURI, "spaces%20in%20name.example.com") { + t.Fatalf("expected escaped hostname path, got %s", r.RequestURI) + } + _, _ = w.Write([]byte(`{"statusCode":200,"id":123,"domainName":"example.com"}`)) + })) + defer ts.Close() + + client := New("test-key", WithBaseURL(ts.URL), WithHTTPClient(ts.Client())) + _, _, err := client.GetRootDomain(context.Background(), "spaces in name.example.com") + if err != nil { + t.Fatalf("expected nil error, got %v", err) } } diff --git a/internal/provider/data_source_dns_records.go b/internal/provider/data_source_dns_records.go index e757eaa..fbd5541 100644 --- a/internal/provider/data_source_dns_records.go +++ b/internal/provider/data_source_dns_records.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -52,30 +53,34 @@ func (d *dnsRecordsDataSource) Metadata(_ context.Context, req datasource.Metada func (d *dnsRecordsDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { resp.Schema = schema.Schema{ - Description: "Get DNS records from Dynu for the domain resolved from a hostname.", + Description: "Lists DNS records for the Dynu root domain resolved from a hostname.", Attributes: map[string]schema.Attribute{ "hostname": schema.StringAttribute{ Required: true, - Description: "Any hostname under the target root domain.", - Validators: []validator.String{stringvalidator.LengthAtLeast(1)}, + 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"), + }, }, - "domain_id": schema.Int64Attribute{Computed: true}, - "domain_name": schema.StringAttribute{Computed: true}, + "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."}, "records": schema.ListNestedAttribute{ - Computed: true, + Computed: true, + Description: "Sorted DNS records for the resolved domain. Timestamps are returned as Dynu provides them.", NestedObject: schema.NestedAttributeObject{Attributes: map[string]schema.Attribute{ - "id": schema.Int64Attribute{Computed: true}, - "domain_id": schema.Int64Attribute{Computed: true}, - "domain_name": schema.StringAttribute{Computed: true}, - "node_name": schema.StringAttribute{Computed: true}, - "hostname": schema.StringAttribute{Computed: true}, - "record_type": schema.StringAttribute{Computed: true}, - "ttl": schema.Int64Attribute{Computed: true}, - "state": schema.BoolAttribute{Computed: true}, - "content": schema.StringAttribute{Computed: true}, - "updated_on": schema.StringAttribute{Computed: true}, - "group": schema.StringAttribute{Computed: true}, - "host": schema.StringAttribute{Computed: true}, + "id": schema.Int64Attribute{Computed: true, Description: "Dynu DNS record ID."}, + "domain_id": schema.Int64Attribute{Computed: true, Description: "Dynu domain ID for this record."}, + "domain_name": schema.StringAttribute{Computed: true, Description: "Domain name for this record."}, + "node_name": schema.StringAttribute{Computed: true, Description: "Node/label portion of the record."}, + "hostname": schema.StringAttribute{Computed: true, Description: "Fully-qualified hostname for the record."}, + "record_type": schema.StringAttribute{Computed: true, Description: "DNS record type (A, CNAME, TXT, etc.)."}, + "ttl": schema.Int64Attribute{Computed: true, Description: "DNS TTL in seconds."}, + "state": schema.BoolAttribute{Computed: true, Description: "Whether this DNS record is active."}, + "content": schema.StringAttribute{Computed: true, Description: "Record content/value."}, + "updated_on": schema.StringAttribute{Computed: true, Description: "Last update timestamp as returned by Dynu."}, + "group": schema.StringAttribute{Computed: true, Description: "Dynu group value for this record."}, + "host": schema.StringAttribute{Computed: true, Description: "Host field as returned by Dynu."}, }}, }, }, @@ -101,6 +106,11 @@ func (d *dnsRecordsDataSource) Read(ctx context.Context, req datasource.ReadRequ return } + if state.Hostname.IsUnknown() || state.Hostname.IsNull() { + resp.Diagnostics.AddAttributeError(path.Root("hostname"), "Invalid hostname", "The hostname must be known and non-null.") + return + } + domainID, domainName, err := d.clientProvider.client.GetRootDomain(ctx, state.Hostname.ValueString()) if err != nil { resp.Diagnostics.AddError("Unable to resolve Dynu domain from hostname", err.Error()) @@ -113,6 +123,8 @@ func (d *dnsRecordsDataSource) Read(ctx context.Context, req datasource.ReadRequ return } + sortDNSRecords(records) + state.DomainID = types.Int64Value(domainID) state.DomainName = types.StringValue(domainName) state.Records = make([]dnsRecordStateItem, 0, len(records)) diff --git a/internal/provider/data_source_domain.go b/internal/provider/data_source_domain.go index 2e1aac3..928d6b0 100644 --- a/internal/provider/data_source_domain.go +++ b/internal/provider/data_source_domain.go @@ -3,9 +3,9 @@ package provider import ( "context" "fmt" + "regexp" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" - "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/path" @@ -18,6 +18,8 @@ var ( _ datasource.DataSourceWithConfigure = &domainDataSource{} ) +var hostnameValidator = regexp.MustCompile(`^([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`) + type domainDataSource struct { clientProvider *providerData } @@ -37,16 +39,19 @@ func (d *domainDataSource) Metadata(_ context.Context, req datasource.MetadataRe func (d *domainDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { resp.Schema = schema.Schema{ - Description: "Get a Dynu DNS domain by hostname.", + Description: "Resolves a hostname to its Dynu root domain and returns domain details.", Attributes: map[string]schema.Attribute{ "hostname": schema.StringAttribute{ Required: true, - Description: "Hostname to resolve to a root domain.", - Validators: []validator.String{stringvalidator.LengthAtLeast(1)}, + Description: "Fully-qualified hostname, such as www.example.com.", + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.RegexMatches(hostnameValidator, "must be a valid fully-qualified hostname"), + }, }, "domain": schema.SingleNestedAttribute{ Computed: true, - Description: "Resolved Dynu DNS domain details.", + Description: "Dynu root domain details for the supplied hostname.", Attributes: domainAttributes(), }, }, @@ -75,7 +80,7 @@ func (d *domainDataSource) Read(ctx context.Context, req datasource.ReadRequest, } if state.Hostname.IsUnknown() || state.Hostname.IsNull() { - resp.Diagnostics.AddAttributeError(path.Root("hostname"), "Invalid hostname", "hostname must be known and non-null.") + resp.Diagnostics.AddAttributeError(path.Root("hostname"), "Invalid hostname", "The hostname must be known and non-null.") return } @@ -91,47 +96,7 @@ func (d *domainDataSource) Read(ctx context.Context, req datasource.ReadRequest, return } - domainValue := mapDomain(*domain) - domainObject, diags := types.ObjectValue( - map[string]attr.Type{ - "id": types.Int64Type, - "name": types.StringType, - "unicode_name": types.StringType, - "token": types.StringType, - "state": types.StringType, - "group": types.StringType, - "ipv4_address": types.StringType, - "ipv6_address": types.StringType, - "ttl": types.Int64Type, - "ipv4": types.BoolType, - "ipv6": types.BoolType, - "ipv4_wildcard_alias": types.BoolType, - "ipv6_wildcard_alias": types.BoolType, - "allow_zone_transfer": types.BoolType, - "dnssec": types.BoolType, - "created_on": types.StringType, - "updated_on": types.StringType, - }, - map[string]attr.Value{ - "id": domainValue.ID, - "name": domainValue.Name, - "unicode_name": domainValue.UnicodeName, - "token": domainValue.Token, - "state": domainValue.State, - "group": domainValue.Group, - "ipv4_address": domainValue.IPv4Address, - "ipv6_address": domainValue.IPv6Address, - "ttl": domainValue.TTL, - "ipv4": domainValue.IPv4, - "ipv6": domainValue.IPv6, - "ipv4_wildcard_alias": domainValue.IPv4WildcardAlias, - "ipv6_wildcard_alias": domainValue.IPv6WildcardAlias, - "allow_zone_transfer": domainValue.AllowZoneTransfer, - "dnssec": domainValue.DNSSEC, - "created_on": domainValue.CreatedOn, - "updated_on": domainValue.UpdatedOn, - }, - ) + domainObject, diags := domainObjectValue(*domain) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return diff --git a/internal/provider/data_source_domains.go b/internal/provider/data_source_domains.go index aadecf5..3c6efe8 100644 --- a/internal/provider/data_source_domains.go +++ b/internal/provider/data_source_domains.go @@ -52,11 +52,11 @@ func (d *domainsDataSource) Metadata(_ context.Context, req datasource.MetadataR func (d *domainsDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { resp.Schema = schema.Schema{ - Description: "List DNS domains from Dynu.", + Description: "Lists DNS domains visible to the configured Dynu API key.", Attributes: map[string]schema.Attribute{ "domains": schema.ListNestedAttribute{ Computed: true, - Description: "List of Dynu DNS domains.", + Description: "Sorted list of domains. Timestamps are returned exactly as Dynu provides them.", NestedObject: schema.NestedAttributeObject{Attributes: domainAttributes()}, }, }, @@ -84,6 +84,8 @@ func (d *domainsDataSource) Read(ctx context.Context, _ datasource.ReadRequest, return } + sortDomains(domains) + state := domainsDataSourceModel{Domains: make([]domainModel, 0, len(domains))} for _, domain := range domains { state.Domains = append(state.Domains, mapDomain(domain)) @@ -94,22 +96,22 @@ func (d *domainsDataSource) Read(ctx context.Context, _ datasource.ReadRequest, func domainAttributes() map[string]schema.Attribute { return map[string]schema.Attribute{ - "id": schema.Int64Attribute{Computed: true}, - "name": schema.StringAttribute{Computed: true}, - "unicode_name": schema.StringAttribute{Computed: true}, - "token": schema.StringAttribute{Computed: true, Sensitive: true}, - "state": schema.StringAttribute{Computed: true}, - "group": schema.StringAttribute{Computed: true}, - "ipv4_address": schema.StringAttribute{Computed: true}, - "ipv6_address": schema.StringAttribute{Computed: true}, - "ttl": schema.Int64Attribute{Computed: true}, - "ipv4": schema.BoolAttribute{Computed: true}, - "ipv6": schema.BoolAttribute{Computed: true}, - "ipv4_wildcard_alias": schema.BoolAttribute{Computed: true}, - "ipv6_wildcard_alias": schema.BoolAttribute{Computed: true}, - "allow_zone_transfer": schema.BoolAttribute{Computed: true}, - "dnssec": schema.BoolAttribute{Computed: true}, - "created_on": schema.StringAttribute{Computed: true}, - "updated_on": schema.StringAttribute{Computed: true}, + "id": schema.Int64Attribute{Computed: true, Description: "Dynu domain ID."}, + "name": schema.StringAttribute{Computed: true, Description: "Primary domain name."}, + "unicode_name": schema.StringAttribute{Computed: true, Description: "Unicode representation of the domain."}, + "token": schema.StringAttribute{Computed: true, Sensitive: true, Description: "Dynu domain token."}, + "state": schema.StringAttribute{Computed: true, Description: "Dynu domain state."}, + "group": schema.StringAttribute{Computed: true, Description: "Dynu domain group."}, + "ipv4_address": schema.StringAttribute{Computed: true, Description: "Configured IPv4 address, if any."}, + "ipv6_address": schema.StringAttribute{Computed: true, Description: "Configured IPv6 address, if any."}, + "ttl": schema.Int64Attribute{Computed: true, Description: "DNS TTL in seconds."}, + "ipv4": schema.BoolAttribute{Computed: true, Description: "Whether IPv4 support is enabled."}, + "ipv6": schema.BoolAttribute{Computed: true, Description: "Whether IPv6 support is enabled."}, + "ipv4_wildcard_alias": schema.BoolAttribute{Computed: true, Description: "Whether IPv4 wildcard alias is enabled."}, + "ipv6_wildcard_alias": schema.BoolAttribute{Computed: true, Description: "Whether IPv6 wildcard alias is enabled."}, + "allow_zone_transfer": schema.BoolAttribute{Computed: true, Description: "Whether zone transfer is allowed."}, + "dnssec": schema.BoolAttribute{Computed: true, Description: "Whether DNSSEC is enabled."}, + "created_on": schema.StringAttribute{Computed: true, Description: "Creation timestamp as returned by Dynu."}, + "updated_on": schema.StringAttribute{Computed: true, Description: "Last update timestamp as returned by Dynu."}, } } diff --git a/internal/provider/mappers.go b/internal/provider/mappers.go index 9561ba5..8dc39ca 100644 --- a/internal/provider/mappers.go +++ b/internal/provider/mappers.go @@ -1,11 +1,35 @@ package provider import ( + "sort" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/dynu/terraform-provider-dynu/internal/dynuclient" ) +var domainObjectTypes = map[string]attr.Type{ + "id": types.Int64Type, + "name": types.StringType, + "unicode_name": types.StringType, + "token": types.StringType, + "state": types.StringType, + "group": types.StringType, + "ipv4_address": types.StringType, + "ipv6_address": types.StringType, + "ttl": types.Int64Type, + "ipv4": types.BoolType, + "ipv6": types.BoolType, + "ipv4_wildcard_alias": types.BoolType, + "ipv6_wildcard_alias": types.BoolType, + "allow_zone_transfer": types.BoolType, + "dnssec": types.BoolType, + "created_on": types.StringType, + "updated_on": types.StringType, +} + func mapDomain(domain dynuclient.Domain) domainModel { return domainModel{ ID: types.Int64Value(domain.ID), @@ -14,8 +38,8 @@ func mapDomain(domain dynuclient.Domain) domainModel { Token: types.StringValue(domain.Token), State: types.StringValue(domain.State), Group: types.StringValue(domain.Group), - IPv4Address: types.StringValue(domain.IPv4Address), - IPv6Address: types.StringValue(domain.IPv6Address), + IPv4Address: mapString(domain.IPv4Address), + IPv6Address: mapString(domain.IPv6Address), TTL: types.Int64Value(domain.TTL), IPv4: types.BoolValue(domain.IPv4), IPv6: types.BoolValue(domain.IPv6), @@ -23,14 +47,59 @@ func mapDomain(domain dynuclient.Domain) domainModel { IPv6WildcardAlias: types.BoolValue(domain.IPv6WildcardAlias), AllowZoneTransfer: types.BoolValue(domain.AllowZoneTransfer), DNSSEC: types.BoolValue(domain.DNSSEC), - CreatedOn: types.StringValue(domain.CreatedOn), - UpdatedOn: types.StringValue(domain.UpdatedOn), + CreatedOn: mapString(domain.CreatedOn), + UpdatedOn: mapString(domain.UpdatedOn), } } +func domainObjectValue(domain dynuclient.Domain) (types.Object, diag.Diagnostics) { + mapped := mapDomain(domain) + + return types.ObjectValue(domainObjectTypes, map[string]attr.Value{ + "id": mapped.ID, + "name": mapped.Name, + "unicode_name": mapped.UnicodeName, + "token": mapped.Token, + "state": mapped.State, + "group": mapped.Group, + "ipv4_address": mapped.IPv4Address, + "ipv6_address": mapped.IPv6Address, + "ttl": mapped.TTL, + "ipv4": mapped.IPv4, + "ipv6": mapped.IPv6, + "ipv4_wildcard_alias": mapped.IPv4WildcardAlias, + "ipv6_wildcard_alias": mapped.IPv6WildcardAlias, + "allow_zone_transfer": mapped.AllowZoneTransfer, + "dnssec": mapped.DNSSEC, + "created_on": mapped.CreatedOn, + "updated_on": mapped.UpdatedOn, + }) +} + func mapString(in string) types.String { if in == "" { return types.StringNull() } return types.StringValue(in) } + +func sortDomains(domains []dynuclient.Domain) { + sort.Slice(domains, func(i, j int) bool { + if domains[i].Name != domains[j].Name { + return domains[i].Name < domains[j].Name + } + return domains[i].ID < domains[j].ID + }) +} + +func sortDNSRecords(records []dynuclient.DNSRecord) { + sort.Slice(records, func(i, j int) bool { + if records[i].Hostname != records[j].Hostname { + return records[i].Hostname < records[j].Hostname + } + if records[i].RecordType != records[j].RecordType { + return records[i].RecordType < records[j].RecordType + } + return records[i].ID < records[j].ID + }) +} diff --git a/internal/provider/mappers_test.go b/internal/provider/mappers_test.go new file mode 100644 index 0000000..c37af5e --- /dev/null +++ b/internal/provider/mappers_test.go @@ -0,0 +1,44 @@ +package provider + +import ( + "testing" + + "github.com/dynu/terraform-provider-dynu/internal/dynuclient" +) + +func TestSortDomains(t *testing.T) { + domains := []dynuclient.Domain{ + {ID: 2, Name: "z.example.com"}, + {ID: 1, Name: "a.example.com"}, + {ID: 3, Name: "a.example.com"}, + } + + sortDomains(domains) + + if domains[0].ID != 1 || domains[1].ID != 3 || domains[2].ID != 2 { + t.Fatalf("unexpected domain sort order: %#v", domains) + } +} + +func TestSortDNSRecords(t *testing.T) { + records := []dynuclient.DNSRecord{ + {ID: 3, Hostname: "b.example.com", RecordType: "TXT"}, + {ID: 1, Hostname: "a.example.com", RecordType: "A"}, + {ID: 2, Hostname: "a.example.com", RecordType: "A"}, + } + + sortDNSRecords(records) + + if records[0].ID != 1 || records[1].ID != 2 || records[2].ID != 3 { + t.Fatalf("unexpected record sort order: %#v", records) + } +} + +func TestMapString(t *testing.T) { + if !mapString("").IsNull() { + t.Fatal("expected empty string to map to null") + } + if mapString("value").ValueString() != "value" { + t.Fatal("expected non-empty string to map to value") + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 6ebaeae..b2981a2 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -3,6 +3,7 @@ package provider import ( "context" "os" + "strings" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/path" @@ -21,7 +22,8 @@ type dynuProvider struct { } type dynuProviderModel struct { - APIKey types.String `tfsdk:"api_key"` + APIKey types.String `tfsdk:"api_key"` + BaseURL types.String `tfsdk:"base_url"` } type providerData struct { @@ -41,12 +43,16 @@ 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 operations.", + Description: "Terraform provider for Dynu DNS read-only data sources.", Attributes: map[string]schema.Attribute{ "api_key": schema.StringAttribute{ Optional: true, Sensitive: true, - Description: "Dynu API key. Can also be provided using DYNU_API_KEY.", + Description: "Dynu API key. If omitted, the provider uses the DYNU_API_KEY environment variable.", + }, + "base_url": schema.StringAttribute{ + Optional: true, + Description: "Override Dynu API base URL. Primarily intended for automated tests.", }, }, } @@ -60,25 +66,28 @@ func (p *dynuProvider) Configure(ctx context.Context, req provider.ConfigureRequ return } - apiKey := os.Getenv("DYNU_API_KEY") - if !data.APIKey.IsNull() { - apiKey = data.APIKey.ValueString() - } - + apiKey := resolveAPIKey(data.APIKey, os.Getenv("DYNU_API_KEY")) if apiKey == "" { resp.Diagnostics.AddAttributeError( path.Root("api_key"), "Missing Dynu API key", - "Set api_key in the provider configuration or DYNU_API_KEY in the environment.", + "Configure api_key in the provider block or set the DYNU_API_KEY environment variable.", ) return } - providerData := &providerData{client: dynuclient.New(apiKey)} + providerData := &providerData{client: newDynuClient(apiKey, data.BaseURL)} resp.DataSourceData = providerData resp.ResourceData = nil } +func resolveAPIKey(configValue types.String, envValue string) string { + if !configValue.IsNull() && !configValue.IsUnknown() { + return strings.TrimSpace(configValue.ValueString()) + } + return strings.TrimSpace(envValue) +} + func (p *dynuProvider) DataSources(_ context.Context) []func() datasource.DataSource { return []func() datasource.DataSource{ NewDomainsDataSource, @@ -90,3 +99,11 @@ func (p *dynuProvider) DataSources(_ context.Context) []func() datasource.DataSo func (p *dynuProvider) Resources(_ context.Context) []func() resource.Resource { return nil } + +func newDynuClient(apiKey string, baseURL types.String) *dynuclient.Client { + if !baseURL.IsNull() && !baseURL.IsUnknown() && strings.TrimSpace(baseURL.ValueString()) != "" { + return dynuclient.New(apiKey, dynuclient.WithBaseURL(strings.TrimSpace(baseURL.ValueString()))) + } + + return dynuclient.New(apiKey) +} diff --git a/internal/provider/provider_acc_test.go b/internal/provider/provider_acc_test.go index fa499e8..df3397e 100644 --- a/internal/provider/provider_acc_test.go +++ b/internal/provider/provider_acc_test.go @@ -1,18 +1,77 @@ package provider import ( + "context" "os" "testing" + + "github.com/dynu/terraform-provider-dynu/internal/dynuclient" ) -func TestAccScaffold(t *testing.T) { - if os.Getenv("TF_ACC") != "1" || os.Getenv("DYNU_API_KEY") == "" { - t.Skip("set TF_ACC=1 and DYNU_API_KEY to enable acceptance tests") +func testAccPreCheck(t *testing.T) { + t.Helper() + if os.Getenv("TF_ACC") != "1" { + t.Skip("set TF_ACC=1 to run acceptance tests") + } + if os.Getenv("DYNU_API_KEY") == "" { + t.Skip("set DYNU_API_KEY to run acceptance tests") + } +} + +func testAccDomainFromEnv(t *testing.T) string { + t.Helper() + domain := os.Getenv("DYNU_DOMAIN") + if domain == "" { + t.Skip("set DYNU_DOMAIN to run domain-specific acceptance tests") + } + return domain +} + +func TestAccDataSourceDomains(t *testing.T) { + testAccPreCheck(t) + + client := dynuclient.New(os.Getenv("DYNU_API_KEY")) + domains, err := client.ListDomains(context.Background()) + if err != nil { + t.Fatalf("ListDomains() failed: %v", err) + } + if len(domains) == 0 { + t.Fatal("expected at least one domain") + } +} + +func TestAccDataSourceDomain(t *testing.T) { + testAccPreCheck(t) + hostname := testAccDomainFromEnv(t) + + client := dynuclient.New(os.Getenv("DYNU_API_KEY")) + domainID, _, err := client.GetRootDomain(context.Background(), hostname) + if err != nil { + t.Fatalf("GetRootDomain() failed: %v", err) + } + domain, err := client.GetDomainByID(context.Background(), domainID) + if err != nil { + t.Fatalf("GetDomainByID() failed: %v", err) + } + if domain.ID == 0 || domain.Name == "" { + t.Fatalf("unexpected domain payload: %#v", domain) + } +} + +func TestAccDataSourceDNSRecords(t *testing.T) { + testAccPreCheck(t) + hostname := testAccDomainFromEnv(t) + + client := dynuclient.New(os.Getenv("DYNU_API_KEY")) + domainID, _, err := client.GetRootDomain(context.Background(), hostname) + if err != nil { + t.Fatalf("GetRootDomain() failed: %v", err) + } + records, err := client.ListDNSRecords(context.Background(), domainID) + if err != nil { + t.Fatalf("ListDNSRecords() failed: %v", err) + } + if records == nil { + t.Fatal("expected records slice, got nil") } - - // Optional: DYNU_DOMAIN may be used by future acceptance test cases. - _ = os.Getenv("DYNU_DOMAIN") - - // Acceptance tests for read-only data sources are intentionally scaffolded in phase 1. - // Add terraform-plugin-testing based test cases in a follow-up with stable fixtures. } diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go new file mode 100644 index 0000000..b47ccd7 --- /dev/null +++ b/internal/provider/provider_test.go @@ -0,0 +1,30 @@ +package provider + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestResolveAPIKey(t *testing.T) { + tests := []struct { + name string + config types.String + env string + want string + }{ + {name: "config wins", config: types.StringValue("config-key"), env: "env-key", want: "config-key"}, + {name: "env fallback", config: types.StringNull(), env: "env-key", want: "env-key"}, + {name: "trim spaces", config: types.StringValue(" config-key "), env: " env-key ", want: "config-key"}, + {name: "unknown uses env", config: types.StringUnknown(), env: "env-key", want: "env-key"}, + {name: "empty when missing", config: types.StringNull(), env: "", want: ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := resolveAPIKey(tc.config, tc.env); got != tc.want { + t.Fatalf("resolveAPIKey() = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/scripts/check.sh b/scripts/check.sh index e9beb14..480a325 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -1,14 +1,21 @@ #!/usr/bin/env bash set -euo pipefail -echo "[check] Running gofmt" +echo "[check] verifying gofmt" files="$(git ls-files '*.go')" -if [[ -n "$files" ]]; then - # shellcheck disable=SC2086 - gofmt -w $files +if [[ -n "${files}" ]]; then + unformatted="$(gofmt -l ${files})" + if [[ -n "${unformatted}" ]]; then + echo "[check][error] gofmt reported unformatted files:" >&2 + echo "${unformatted}" >&2 + exit 1 + fi fi -echo "[check] Running go test" +echo "[check] running go vet" +go vet ./... + +echo "[check] running go test" go test ./... -echo "[check] Completed" +echo "[check] complete" diff --git a/scripts/testacc.sh b/scripts/testacc.sh index 5bb482a..b083e58 100755 --- a/scripts/testacc.sh +++ b/scripts/testacc.sh @@ -12,8 +12,8 @@ if [[ -z "${DYNU_API_KEY:-}" ]]; then fi if [[ -z "${DYNU_DOMAIN:-}" ]]; then - echo "[testacc][info] DYNU_DOMAIN is not set; running baseline acceptance scaffolding" + echo "[testacc][warn] DYNU_DOMAIN not set; domain-specific acceptance tests will skip" fi -echo "[testacc] Running acceptance tests" -go test ./internal/provider -run TestAcc -count=1 +echo "[testacc] running acceptance tests" +go test ./internal/provider -run '^TestAcc' -count=1 -v