Harden read-only provider with tests, docs, and CI

This commit is contained in:
beatz174-bit
2026-04-21 14:39:36 +10:00
parent cf47a6ed48
commit 6cc1b206dc
24 changed files with 711 additions and 246 deletions
+20
View File
@@ -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
+63 -54
View File
@@ -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.
+39
View File
@@ -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)
+25
View File
@@ -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[*]`.
+34
View File
@@ -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)
+32
View File
@@ -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.
@@ -1,3 +1,13 @@
terraform {
required_providers {
dynu = {
source = "dynu/dynu"
}
}
}
provider "dynu" {}
data "dynu_dns_records" "records" {
hostname = "www.example.com"
}
@@ -1,3 +1,13 @@
terraform {
required_providers {
dynu = {
source = "dynu/dynu"
}
}
}
provider "dynu" {}
data "dynu_domain" "selected" {
hostname = "www.example.com"
}
@@ -1,3 +1,13 @@
terraform {
required_providers {
dynu = {
source = "dynu/dynu"
}
}
}
provider "dynu" {}
data "dynu_domains" "all" {}
output "domains" {
+9 -1
View File
@@ -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
}
+14 -14
View File
@@ -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
)
+38 -44
View File
@@ -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=
+19 -6
View File
@@ -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)
}
+67 -11
View File
@@ -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)
}
}
+29 -17
View File
@@ -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,
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))
+12 -47
View File
@@ -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
+21 -19
View File
@@ -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."},
}
}
+73 -4
View File
@@ -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
})
}
+44
View File
@@ -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")
}
}
+26 -9
View File
@@ -3,6 +3,7 @@ package provider
import (
"context"
"os"
"strings"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/path"
@@ -22,6 +23,7 @@ type dynuProvider struct {
type dynuProviderModel struct {
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)
}
+66 -7
View File
@@ -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")
}
}
// Optional: DYNU_DOMAIN may be used by future acceptance test cases.
_ = os.Getenv("DYNU_DOMAIN")
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
}
// 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.
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")
}
}
+30
View File
@@ -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)
}
})
}
}
+13 -6
View File
@@ -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"
+3 -3
View File
@@ -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