From b268d7a6aa78bb98ddf55671eac62beaa95d8e9b Mon Sep 17 00:00:00 2001 From: beatz174-bit Date: Tue, 21 Apr 2026 13:02:42 +1000 Subject: [PATCH] Implement read-only Dynu Terraform provider skeleton --- README.md | 84 ++++++++ .../dynu_dns_records/data-source.tf | 7 + .../data-sources/dynu_domain/data-source.tf | 7 + .../data-sources/dynu_domains/data-source.tf | 5 + examples/provider/provider.tf | 9 + go.mod | 35 ++++ go.sum | 97 +++++++++ internal/dynuclient/client.go | 193 ++++++++++++++++++ internal/dynuclient/client_test.go | 48 +++++ internal/provider/data_source_dns_records.go | 137 +++++++++++++ internal/provider/data_source_domain.go | 142 +++++++++++++ internal/provider/data_source_domains.go | 115 +++++++++++ internal/provider/mappers.go | 36 ++++ internal/provider/provider.go | 92 +++++++++ internal/provider/provider_acc_test.go | 15 ++ main.go | 19 ++ 16 files changed, 1041 insertions(+) create mode 100644 README.md create mode 100644 examples/data-sources/dynu_dns_records/data-source.tf create mode 100644 examples/data-sources/dynu_domain/data-source.tf create mode 100644 examples/data-sources/dynu_domains/data-source.tf create mode 100644 examples/provider/provider.tf create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/dynuclient/client.go create mode 100644 internal/dynuclient/client_test.go create mode 100644 internal/provider/data_source_dns_records.go create mode 100644 internal/provider/data_source_domain.go create mode 100644 internal/provider/data_source_domains.go create mode 100644 internal/provider/mappers.go create mode 100644 internal/provider/provider.go create mode 100644 internal/provider/provider_acc_test.go create mode 100644 main.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..94c5720 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# terraform-provider-dynu + +Terraform provider for Dynu DNS using the Terraform Plugin Framework. + +> Current phase is **read-only**: this provider implements provider configuration and data sources only. + +## Features + +- Provider configuration with API key authentication. +- Environment variable support (`DYNU_API_KEY`). +- Read-only data sources: + - `dynu_domains` + - `dynu_domain` + - `dynu_dns_records` + +## Requirements + +- Terraform >= 1.5 +- Go >= 1.22 (for building) +- A Dynu API key + +## Provider configuration + +```hcl +provider "dynu" { + api_key = var.dynu_api_key +} +``` + +You can also omit `api_key` and set `DYNU_API_KEY` in your environment. + +## Data sources + +### dynu_domains + +Returns all DNS domains associated with the account. + +```hcl +data "dynu_domains" "all" {} +``` + +### dynu_domain + +Resolves the root domain from a hostname, then returns full domain details. + +```hcl +data "dynu_domain" "selected" { + hostname = "www.example.com" +} +``` + +### dynu_dns_records + +Resolves the root domain from a hostname, then returns DNS records for that domain. + +```hcl +data "dynu_dns_records" "records" { + hostname = "www.example.com" +} +``` + +## Development + +```bash +./codex/setup.sh +./codex/maintain.sh +``` + +Run tests: + +```bash +go test ./... +``` + +Acceptance tests (requires live Dynu credentials): + +```bash +TF_ACC=1 DYNU_API_KEY=... go test ./internal/provider -run TestAcc +``` + +## Limitations + +- No Terraform resources are implemented in this phase. +- No write operations are supported by provider code in this phase. diff --git a/examples/data-sources/dynu_dns_records/data-source.tf b/examples/data-sources/dynu_dns_records/data-source.tf new file mode 100644 index 0000000..3df6593 --- /dev/null +++ b/examples/data-sources/dynu_dns_records/data-source.tf @@ -0,0 +1,7 @@ +data "dynu_dns_records" "records" { + hostname = "www.example.com" +} + +output "records" { + value = data.dynu_dns_records.records.records +} diff --git a/examples/data-sources/dynu_domain/data-source.tf b/examples/data-sources/dynu_domain/data-source.tf new file mode 100644 index 0000000..aff9b93 --- /dev/null +++ b/examples/data-sources/dynu_domain/data-source.tf @@ -0,0 +1,7 @@ +data "dynu_domain" "selected" { + hostname = "www.example.com" +} + +output "domain" { + value = data.dynu_domain.selected.domain +} diff --git a/examples/data-sources/dynu_domains/data-source.tf b/examples/data-sources/dynu_domains/data-source.tf new file mode 100644 index 0000000..9c6c1f2 --- /dev/null +++ b/examples/data-sources/dynu_domains/data-source.tf @@ -0,0 +1,5 @@ +data "dynu_domains" "all" {} + +output "domains" { + value = data.dynu_domains.all.domains +} diff --git a/examples/provider/provider.tf b/examples/provider/provider.tf new file mode 100644 index 0000000..3462b74 --- /dev/null +++ b/examples/provider/provider.tf @@ -0,0 +1,9 @@ +provider "dynu" { + # api_key can be omitted when DYNU_API_KEY is set + api_key = var.dynu_api_key +} + +variable "dynu_api_key" { + type = string + sensitive = true +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..eb52fc3 --- /dev/null +++ b/go.mod @@ -0,0 +1,35 @@ +module github.com/dynu/terraform-provider-dynu + +go 1.23.0 + +toolchain go1.24.3 + +require ( + github.com/hashicorp/terraform-plugin-framework v1.14.1 + github.com/hashicorp/terraform-plugin-framework-validators v0.17.0 +) + +require ( + github.com/fatih/color v1.17.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-uuid v1.0.3 // indirect + github.com/hashicorp/terraform-plugin-go v0.27.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-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/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/oklog/run v1.1.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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..adc37fc --- /dev/null +++ b/go.sum @@ -0,0 +1,97 @@ +github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= +github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= +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/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/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-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-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-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= +github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= +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/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/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/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= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +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= +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= +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 new file mode 100644 index 0000000..705a34a --- /dev/null +++ b/internal/dynuclient/client.go @@ -0,0 +1,193 @@ +package dynuclient + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const defaultBaseURL = "https://api.dynu.com/v2" + +type Client struct { + apiKey string + baseURL string + httpClient *http.Client +} + +type Option func(*Client) + +func WithHTTPClient(httpClient *http.Client) Option { + return func(c *Client) { + c.httpClient = httpClient + } +} + +func WithBaseURL(baseURL string) Option { + return func(c *Client) { + c.baseURL = strings.TrimRight(baseURL, "/") + } +} + +func New(apiKey string, opts ...Option) *Client { + c := &Client{ + apiKey: apiKey, + baseURL: defaultBaseURL, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } + + for _, opt := range opts { + opt(c) + } + + return c +} + +type apiException struct { + StatusCode int `json:"statusCode"` + Type string `json:"type"` + Message string `json:"message"` +} + +type apiResponse struct { + StatusCode int `json:"statusCode"` + Exception *apiException `json:"exception"` +} + +type Domain struct { + ID int64 `json:"id"` + Name string `json:"name"` + UnicodeName string `json:"unicodeName"` + Token string `json:"token"` + State string `json:"state"` + Group string `json:"group"` + IPv4Address string `json:"ipv4Address"` + IPv6Address string `json:"ipv6Address"` + TTL int64 `json:"ttl"` + IPv4 bool `json:"ipv4"` + IPv6 bool `json:"ipv6"` + IPv4WildcardAlias bool `json:"ipv4WildcardAlias"` + IPv6WildcardAlias bool `json:"ipv6WildcardAlias"` + AllowZoneTransfer bool `json:"allowZoneTransfer"` + DNSSEC bool `json:"dnssec"` + CreatedOn string `json:"createdOn"` + UpdatedOn string `json:"updatedOn"` +} + +type DNSRecord struct { + ID int64 `json:"id"` + DomainID int64 `json:"domainId"` + DomainName string `json:"domainName"` + NodeName string `json:"nodeName"` + Hostname string `json:"hostname"` + RecordType string `json:"recordType"` + State bool `json:"state"` + TTL int64 `json:"ttl"` + Content string `json:"content"` + UpdatedOn string `json:"updatedOn"` + Group string `json:"group"` + Host string `json:"host"` +} + +type listDomainsResponse struct { + apiResponse + Domains []Domain `json:"domains"` +} + +type getDomainResponse struct { + apiResponse + Domain +} + +type listDNSRecordsResponse struct { + apiResponse + DNSRecords []DNSRecord `json:"dnsRecords"` +} + +type getRootResponse struct { + apiResponse + ID int64 `json:"id"` + Hostname string `json:"hostname"` + DomainName string `json:"domainName"` + Node string `json:"node"` +} + +func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) { + var resp listDomainsResponse + if err := c.doGET(ctx, "/dns", &resp); err != nil { + return nil, err + } + return resp.Domains, nil +} + +func (c *Client) GetDomainByID(ctx context.Context, domainID int64) (*Domain, error) { + var resp getDomainResponse + if err := c.doGET(ctx, fmt.Sprintf("/dns/%d", domainID), &resp); err != nil { + return nil, err + } + return &resp.Domain, nil +} + +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 { + return 0, "", err + } + + if resp.ID == 0 || resp.DomainName == "" { + return 0, "", errors.New("dynu API returned an incomplete root domain response") + } + + return resp.ID, resp.DomainName, nil +} + +func (c *Client) ListDNSRecords(ctx context.Context, domainID int64) ([]DNSRecord, error) { + var resp listDNSRecordsResponse + if err := c.doGET(ctx, fmt.Sprintf("/dns/%d/record", domainID), &resp); err != nil { + return nil, err + } + return resp.DNSRecords, nil +} + +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) + if err != nil { + return err + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("API-Key", c.apiKey) + + res, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + payload, err := io.ReadAll(res.Body) + if err != nil { + return err + } + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return fmt.Errorf("dynu API returned status %d: %s", res.StatusCode, strings.TrimSpace(string(payload))) + } + + if err := json.Unmarshal(payload, target); err != nil { + return fmt.Errorf("failed to decode dynu API response: %w", err) + } + + 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) + } + + return nil +} diff --git a/internal/dynuclient/client_test.go b/internal/dynuclient/client_test.go new file mode 100644 index 0000000..a7d004b --- /dev/null +++ b/internal/dynuclient/client_test.go @@ -0,0 +1,48 @@ +package dynuclient + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestListDomains(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) + } + if got := r.Header.Get("API-Key"); got != "test-key" { + 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"}]}`)) + })) + defer ts.Close() + + client := New("test-key", WithBaseURL(ts.URL), WithHTTPClient(ts.Client())) + domains, err := client.ListDomains(context.Background()) + 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) + } +} + +func TestDoGetErrorResponse(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"}}`)) + })) + 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") + } +} diff --git a/internal/provider/data_source_dns_records.go b/internal/provider/data_source_dns_records.go new file mode 100644 index 0000000..e757eaa --- /dev/null +++ b/internal/provider/data_source_dns_records.go @@ -0,0 +1,137 @@ +package provider + +import ( + "context" + "fmt" + + "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/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ datasource.DataSource = &dnsRecordsDataSource{} + _ datasource.DataSourceWithConfigure = &dnsRecordsDataSource{} +) + +type dnsRecordsDataSource struct { + clientProvider *providerData +} + +type dnsRecordsDataSourceModel struct { + Hostname types.String `tfsdk:"hostname"` + DomainID types.Int64 `tfsdk:"domain_id"` + DomainName types.String `tfsdk:"domain_name"` + Records []dnsRecordStateItem `tfsdk:"records"` +} + +type dnsRecordStateItem struct { + ID types.Int64 `tfsdk:"id"` + DomainID types.Int64 `tfsdk:"domain_id"` + DomainName types.String `tfsdk:"domain_name"` + NodeName types.String `tfsdk:"node_name"` + Hostname types.String `tfsdk:"hostname"` + RecordType types.String `tfsdk:"record_type"` + TTL types.Int64 `tfsdk:"ttl"` + State types.Bool `tfsdk:"state"` + Content types.String `tfsdk:"content"` + UpdatedOn types.String `tfsdk:"updated_on"` + Group types.String `tfsdk:"group"` + Host types.String `tfsdk:"host"` +} + +func NewDNSRecordsDataSource() datasource.DataSource { + return &dnsRecordsDataSource{} +} + +func (d *dnsRecordsDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_dns_records" +} + +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.", + Attributes: map[string]schema.Attribute{ + "hostname": schema.StringAttribute{ + Required: true, + Description: "Any hostname under the target root domain.", + Validators: []validator.String{stringvalidator.LengthAtLeast(1)}, + }, + "domain_id": schema.Int64Attribute{Computed: true}, + "domain_name": schema.StringAttribute{Computed: true}, + "records": schema.ListNestedAttribute{ + Computed: true, + 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}, + }}, + }, + }, + } +} + +func (d *dnsRecordsDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + providerData, ok := req.ProviderData.(*providerData) + if !ok { + resp.Diagnostics.AddError("Unexpected data source configure type", fmt.Sprintf("Expected *providerData, got %T", req.ProviderData)) + return + } + d.clientProvider = providerData +} + +func (d *dnsRecordsDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var state dnsRecordsDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + 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()) + return + } + + records, err := d.clientProvider.client.ListDNSRecords(ctx, domainID) + if err != nil { + resp.Diagnostics.AddError("Unable to list Dynu DNS records", err.Error()) + return + } + + state.DomainID = types.Int64Value(domainID) + state.DomainName = types.StringValue(domainName) + state.Records = make([]dnsRecordStateItem, 0, len(records)) + for _, record := range records { + state.Records = append(state.Records, dnsRecordStateItem{ + ID: types.Int64Value(record.ID), + DomainID: types.Int64Value(record.DomainID), + DomainName: mapString(record.DomainName), + NodeName: mapString(record.NodeName), + Hostname: mapString(record.Hostname), + RecordType: mapString(record.RecordType), + TTL: types.Int64Value(record.TTL), + State: types.BoolValue(record.State), + Content: mapString(record.Content), + UpdatedOn: mapString(record.UpdatedOn), + Group: mapString(record.Group), + Host: mapString(record.Host), + }) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} diff --git a/internal/provider/data_source_domain.go b/internal/provider/data_source_domain.go new file mode 100644 index 0000000..2e1aac3 --- /dev/null +++ b/internal/provider/data_source_domain.go @@ -0,0 +1,142 @@ +package provider + +import ( + "context" + "fmt" + + "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" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ datasource.DataSource = &domainDataSource{} + _ datasource.DataSourceWithConfigure = &domainDataSource{} +) + +type domainDataSource struct { + clientProvider *providerData +} + +type domainDataSourceModel struct { + Hostname types.String `tfsdk:"hostname"` + Domain types.Object `tfsdk:"domain"` +} + +func NewDomainDataSource() datasource.DataSource { + return &domainDataSource{} +} + +func (d *domainDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_domain" +} + +func (d *domainDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Get a Dynu DNS domain by hostname.", + Attributes: map[string]schema.Attribute{ + "hostname": schema.StringAttribute{ + Required: true, + Description: "Hostname to resolve to a root domain.", + Validators: []validator.String{stringvalidator.LengthAtLeast(1)}, + }, + "domain": schema.SingleNestedAttribute{ + Computed: true, + Description: "Resolved Dynu DNS domain details.", + Attributes: domainAttributes(), + }, + }, + } +} + +func (d *domainDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + providerData, ok := req.ProviderData.(*providerData) + if !ok { + resp.Diagnostics.AddError("Unexpected data source configure type", fmt.Sprintf("Expected *providerData, got %T", req.ProviderData)) + return + } + + d.clientProvider = providerData +} + +func (d *domainDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var state domainDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + if state.Hostname.IsUnknown() || state.Hostname.IsNull() { + resp.Diagnostics.AddAttributeError(path.Root("hostname"), "Invalid hostname", "hostname must be known and non-null.") + return + } + + domainID, _, err := d.clientProvider.client.GetRootDomain(ctx, state.Hostname.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Unable to resolve Dynu domain from hostname", err.Error()) + return + } + + domain, err := d.clientProvider.client.GetDomainByID(ctx, domainID) + if err != nil { + resp.Diagnostics.AddError("Unable to get Dynu domain", err.Error()) + 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, + }, + ) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + state.Domain = domainObject + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} diff --git a/internal/provider/data_source_domains.go b/internal/provider/data_source_domains.go new file mode 100644 index 0000000..aadecf5 --- /dev/null +++ b/internal/provider/data_source_domains.go @@ -0,0 +1,115 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ datasource.DataSource = &domainsDataSource{} + _ datasource.DataSourceWithConfigure = &domainsDataSource{} +) + +type domainsDataSource struct { + clientProvider *providerData +} + +type domainsDataSourceModel struct { + Domains []domainModel `tfsdk:"domains"` +} + +type domainModel struct { + ID types.Int64 `tfsdk:"id"` + Name types.String `tfsdk:"name"` + UnicodeName types.String `tfsdk:"unicode_name"` + Token types.String `tfsdk:"token"` + State types.String `tfsdk:"state"` + Group types.String `tfsdk:"group"` + IPv4Address types.String `tfsdk:"ipv4_address"` + IPv6Address types.String `tfsdk:"ipv6_address"` + TTL types.Int64 `tfsdk:"ttl"` + IPv4 types.Bool `tfsdk:"ipv4"` + IPv6 types.Bool `tfsdk:"ipv6"` + IPv4WildcardAlias types.Bool `tfsdk:"ipv4_wildcard_alias"` + IPv6WildcardAlias types.Bool `tfsdk:"ipv6_wildcard_alias"` + AllowZoneTransfer types.Bool `tfsdk:"allow_zone_transfer"` + DNSSEC types.Bool `tfsdk:"dnssec"` + CreatedOn types.String `tfsdk:"created_on"` + UpdatedOn types.String `tfsdk:"updated_on"` +} + +func NewDomainsDataSource() datasource.DataSource { + return &domainsDataSource{} +} + +func (d *domainsDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_domains" +} + +func (d *domainsDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "List DNS domains from Dynu.", + Attributes: map[string]schema.Attribute{ + "domains": schema.ListNestedAttribute{ + Computed: true, + Description: "List of Dynu DNS domains.", + NestedObject: schema.NestedAttributeObject{Attributes: domainAttributes()}, + }, + }, + } +} + +func (d *domainsDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + providerData, ok := req.ProviderData.(*providerData) + if !ok { + resp.Diagnostics.AddError("Unexpected data source configure type", fmt.Sprintf("Expected *providerData, got %T", req.ProviderData)) + return + } + + d.clientProvider = providerData +} + +func (d *domainsDataSource) Read(ctx context.Context, _ datasource.ReadRequest, resp *datasource.ReadResponse) { + domains, err := d.clientProvider.client.ListDomains(ctx) + if err != nil { + resp.Diagnostics.AddError("Unable to list Dynu domains", err.Error()) + return + } + + state := domainsDataSourceModel{Domains: make([]domainModel, 0, len(domains))} + for _, domain := range domains { + state.Domains = append(state.Domains, mapDomain(domain)) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +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}, + } +} diff --git a/internal/provider/mappers.go b/internal/provider/mappers.go new file mode 100644 index 0000000..9561ba5 --- /dev/null +++ b/internal/provider/mappers.go @@ -0,0 +1,36 @@ +package provider + +import ( + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/dynu/terraform-provider-dynu/internal/dynuclient" +) + +func mapDomain(domain dynuclient.Domain) domainModel { + return domainModel{ + ID: types.Int64Value(domain.ID), + Name: types.StringValue(domain.Name), + UnicodeName: types.StringValue(domain.UnicodeName), + 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), + TTL: types.Int64Value(domain.TTL), + IPv4: types.BoolValue(domain.IPv4), + IPv6: types.BoolValue(domain.IPv6), + IPv4WildcardAlias: types.BoolValue(domain.IPv4WildcardAlias), + 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), + } +} + +func mapString(in string) types.String { + if in == "" { + return types.StringNull() + } + return types.StringValue(in) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..6ebaeae --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,92 @@ +package provider + +import ( + "context" + "os" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/dynu/terraform-provider-dynu/internal/dynuclient" +) + +var _ provider.Provider = &dynuProvider{} + +type dynuProvider struct { + version string +} + +type dynuProviderModel struct { + APIKey types.String `tfsdk:"api_key"` +} + +type providerData struct { + client *dynuclient.Client +} + +func New(version string) func() provider.Provider { + return func() provider.Provider { + return &dynuProvider{version: version} + } +} + +func (p *dynuProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) { + resp.TypeName = "dynu" + resp.Version = p.version +} + +func (p *dynuProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Terraform provider for Dynu DNS read-only operations.", + 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.", + }, + }, + } +} + +func (p *dynuProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { + var data dynuProviderModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + apiKey := os.Getenv("DYNU_API_KEY") + if !data.APIKey.IsNull() { + apiKey = data.APIKey.ValueString() + } + + 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.", + ) + return + } + + providerData := &providerData{client: dynuclient.New(apiKey)} + resp.DataSourceData = providerData + resp.ResourceData = nil +} + +func (p *dynuProvider) DataSources(_ context.Context) []func() datasource.DataSource { + return []func() datasource.DataSource{ + NewDomainsDataSource, + NewDomainDataSource, + NewDNSRecordsDataSource, + } +} + +func (p *dynuProvider) Resources(_ context.Context) []func() resource.Resource { + return nil +} diff --git a/internal/provider/provider_acc_test.go b/internal/provider/provider_acc_test.go new file mode 100644 index 0000000..56cc54a --- /dev/null +++ b/internal/provider/provider_acc_test.go @@ -0,0 +1,15 @@ +package provider + +import ( + "os" + "testing" +) + +func TestAccScaffold(t *testing.T) { + if os.Getenv("TF_ACC") == "" || os.Getenv("DYNU_API_KEY") == "" { + t.Skip("set TF_ACC=1 and DYNU_API_KEY to enable acceptance tests") + } + + // 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/main.go b/main.go new file mode 100644 index 0000000..1331619 --- /dev/null +++ b/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "context" + "log" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + + "github.com/dynu/terraform-provider-dynu/internal/provider" +) + +func main() { + err := providerserver.Serve(context.Background(), provider.New("dev"), providerserver.ServeOpts{ + Address: "registry.terraform.io/dynu/dynu", + }) + if err != nil { + log.Fatal(err.Error()) + } +}