├── .editorconfig ├── .github ├── release.yml └── workflows │ ├── cleanup_zones.yaml │ ├── release.yaml │ ├── spelling.yaml │ └── test.yaml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── LICENSE ├── Makefile ├── README.md ├── RELEASING.md ├── docs ├── data-sources │ ├── nameservers.md │ ├── records.md │ └── zone.md ├── functions │ └── idna.md ├── guides │ ├── investigating-rate-limits.md │ └── migration-from-timohirt-hetznerdns.md ├── index.md └── resources │ ├── primary_server.md │ ├── record.md │ └── zone.md ├── examples ├── data-sources │ ├── hetznerdns_nameservers │ │ └── data-source.tf │ ├── hetznerdns_record │ │ └── data-source.tf │ └── hetznerdns_zone │ │ └── data-source.tf ├── functions │ └── idna │ │ └── function.tf ├── provider │ └── provider.tf └── resources │ ├── hetznerdns_primary_server │ ├── import.sh │ └── resource.tf │ ├── hetznerdns_record │ ├── import.sh │ └── resource.tf │ └── hetznerdns_zone │ ├── import.sh │ └── resource.tf ├── go.mod ├── go.sum ├── internal ├── api │ ├── client.go │ ├── client_test.go │ ├── nameserver.go │ ├── primary_server.go │ ├── record.go │ ├── utils.go │ ├── zone.go │ └── zone_test.go ├── provider │ ├── idna_function.go │ ├── idna_function_test.go │ ├── nameserver_data_source.go │ ├── nameserver_data_source_test.go │ ├── primary_server_resource.go │ ├── primary_server_resource_test.go │ ├── provider.go │ ├── provider_test.go │ ├── record_resource.go │ ├── record_resource_test.go │ ├── records_data_source.go │ ├── records_data_source_test.go │ ├── zone_data_source.go │ ├── zone_data_source_test.go │ ├── zone_resource.go │ └── zone_resource_test.go └── utils │ ├── ip.go │ ├── ip_test.go │ ├── provider.go │ ├── txt.go │ └── txt_test.go ├── main.go ├── renovate.json ├── templates └── guides │ ├── investigating-rate-limits.md │ └── migration-from-timohirt-hetznerdns.md ├── terraform-registry-manifest.json └── tools └── tools.go /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | max_line_length = 160 11 | 12 | [*.tf] 13 | max_line_length = off 14 | 15 | [Makefile] 16 | indent_style = tab 17 | 18 | [*.go] 19 | indent_size = tab 20 | indent_style = tab 21 | ij_go_group_stdlib_imports = true 22 | ij_go_import_sorting = goimports 23 | ij_go_move_all_imports_in_one_declaration = true 24 | ij_go_move_all_stdlib_imports_in_one_group = true 25 | ij_go_remove_redundant_import_aliases = true 26 | ij_go_run_go_fmt_on_reformat = true 27 | 28 | [*.{yaml,yml}] 29 | indent_size = 2 -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | categories: 3 | - title: 🚨 Breaking Changes 4 | labels: 5 | - Kind/Breaking 6 | - title: ✨ Features 7 | labels: 8 | - Kind/Feature 9 | - title: 🐛 Bug Fixes and Security 10 | labels: 11 | - Kind/Bug 12 | - Kind/Security 13 | - title: 🌟 Improvements 14 | labels: 15 | - Kind/Enhancement 16 | - title: 📦 Dependencies 17 | labels: 18 | - Kind/Dependency 19 | - title: 📚 Miscellaneous 20 | labels: 21 | - "*" 22 | exclude_labels: 23 | - Kind/Breaking 24 | - Kind/Feature 25 | - Kind/Bug 26 | - Kind/Security 27 | - Kind/Enhancement 28 | - Kind/Dependency 29 | -------------------------------------------------------------------------------- /.github/workflows/cleanup_zones.yaml: -------------------------------------------------------------------------------- 1 | name: "Cleanup DNS Zones" 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 0 * * *" 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | cleanup: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Cleanup DNS Zones 16 | env: 17 | HETZNER_DNS_TOKEN: ${{ secrets.HETZNER_DNS_API_TOKEN }} 18 | run: | 19 | curl "https://dns.hetzner.com/api/v1/zones" -H "Auth-Api-Token: ${HETZNER_DNS_TOKEN}" -s | jq -r '.zones[] | .id' | xargs -I {} curl -XDELETE "https://dns.hetzner.com/api/v1/zones/{}" -H "Auth-Api-Token: ${HETZNER_DNS_TOKEN}" 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | # Terraform Provider release workflow. 2 | name: Release 3 | 4 | # This GitHub action creates a release when a tag that matches the pattern 5 | # "v*" (e.g. v0.1.0) is created. 6 | on: 7 | push: 8 | tags: 9 | - 'v*' 10 | 11 | # Releases need permissions to read and write the repository contents. 12 | # GitHub considers creating releases and uploading assets as writing contents. 13 | permissions: 14 | contents: write 15 | 16 | jobs: 17 | goreleaser: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 21 | with: 22 | # Allow goreleaser to access older tag information. 23 | fetch-depth: 0 24 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 25 | with: 26 | go-version-file: 'go.mod' 27 | cache: true 28 | - name: Import GPG key 29 | uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6.3.0 30 | id: import_gpg 31 | with: 32 | gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} 33 | passphrase: ${{ secrets.PASSPHRASE }} 34 | - name: Run GoReleaser 35 | uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0 36 | with: 37 | distribution: goreleaser 38 | version: "~> v2" 39 | args: release --clean 40 | env: 41 | # GitHub sets the GITHUB_TOKEN secret automatically. 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} 44 | -------------------------------------------------------------------------------- /.github/workflows/spelling.yaml: -------------------------------------------------------------------------------- 1 | name: Spell checking 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | codespell: 16 | name: Check for spelling errors 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 20 | - uses: codespell-project/actions-codespell@master 21 | with: 22 | check_filenames: true 23 | # When using this Action in other repos, the --skip option below can be removed 24 | skip: ./.git,go.mod,go.sum 25 | ignore_words_list: AtLeast,AtMost 26 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Terraform Provider testing workflow. 3 | name: Tests 4 | 5 | # This GitHub action runs your tests for each pull request and push. 6 | # Optionally, you can turn it on using a schedule for regular testing. 7 | on: 8 | workflow_dispatch: 9 | 10 | push: 11 | branches: 12 | - main 13 | paths-ignore: 14 | - 'README.md' 15 | 16 | pull_request: 17 | paths-ignore: 18 | - 'README.md' 19 | 20 | # Testing only needs permissions to read the repository contents. 21 | permissions: 22 | contents: read 23 | 24 | jobs: 25 | # Ensure project builds before running testing matrix 26 | build: 27 | name: Build 28 | runs-on: ubuntu-latest 29 | timeout-minutes: 5 30 | steps: 31 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 32 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 33 | with: 34 | go-version-file: 'go.mod' 35 | cache: true 36 | - run: go mod download 37 | - run: go build -v . 38 | - run: go test -v ./... 39 | - name: Run linters 40 | uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 41 | with: 42 | version: latest 43 | 44 | - run: go run github.com/bflad/tfproviderlint/cmd/tfproviderlintx@latest ./... 45 | 46 | generate: 47 | runs-on: ubuntu-latest 48 | steps: 49 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 50 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 51 | with: 52 | go-version-file: 'go.mod' 53 | cache: true 54 | - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 55 | with: 56 | terraform_wrapper: false 57 | - run: go mod tidy 58 | - name: git diff 59 | run: | 60 | git diff --compact-summary --exit-code || \ 61 | (echo; echo "Run 'go mod tidy' command and commit."; git diff; exit 1) 62 | - run: go generate ./... 63 | - name: git diff 64 | run: | 65 | git diff --compact-summary --exit-code || \ 66 | (echo; echo "Unexpected difference in directories after code generation. Run 'go generate ./...' command and commit."; git diff; exit 1) 67 | 68 | # Run acceptance tests in a matrix with Terraform CLI versions 69 | test: 70 | name: Terraform Provider Acceptance Tests 71 | needs: build 72 | concurrency: acctests 73 | runs-on: ubuntu-latest 74 | timeout-minutes: 15 75 | strategy: 76 | fail-fast: false 77 | matrix: 78 | # list whatever Terraform versions here you would like to support 79 | terraform: 80 | - '1.10.*' 81 | steps: 82 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 83 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 84 | with: 85 | go-version-file: 'go.mod' 86 | cache: true 87 | - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 88 | with: 89 | terraform_version: ${{ matrix.terraform }} 90 | terraform_wrapper: false 91 | - run: go mod download 92 | - run: go test -v -cover ./internal/provider/ 93 | env: 94 | TF_ACC: "1" 95 | HETZNER_DNS_TOKEN: ${{ secrets.HETZNER_DNS_API_TOKEN }} 96 | timeout-minutes: 10 97 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | .vscode 3 | .idea -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | default: none 4 | enable: 5 | - asciicheck 6 | - contextcheck 7 | - copyloopvar 8 | - dupword 9 | - durationcheck 10 | - errcheck 11 | - forbidigo 12 | - forcetypeassert 13 | - gocheckcompilerdirectives 14 | - gochecknoglobals 15 | - gochecknoinits 16 | - gocognit 17 | - gocritic 18 | - godot 19 | - gomoddirectives 20 | - gosec 21 | - govet 22 | - ineffassign 23 | - lll 24 | - makezero 25 | - mirror 26 | - misspell 27 | - nilerr 28 | - nlreturn 29 | - noctx 30 | - nolintlint 31 | - nonamedreturns 32 | - perfsprint 33 | - prealloc 34 | - predeclared 35 | - staticcheck 36 | - tagalign 37 | - tagliatelle 38 | - testifylint 39 | - unconvert 40 | - unparam 41 | - unused 42 | - wastedassign 43 | - wrapcheck 44 | - wsl 45 | settings: 46 | lll: 47 | line-length: 160 48 | tab-width: 4 49 | tagliatelle: 50 | case: 51 | rules: 52 | json: snake 53 | tfsdk: snake 54 | use-field-name: true 55 | exclusions: 56 | generated: lax 57 | presets: 58 | - comments 59 | - common-false-positives 60 | - legacy 61 | - std-error-handling 62 | rules: 63 | - linters: 64 | - dupword 65 | - funlen 66 | path: _test\.go 67 | paths: 68 | - third_party$ 69 | - builtin$ 70 | - examples$ 71 | issues: 72 | max-issues-per-linter: 0 73 | max-same-issues: 0 74 | formatters: 75 | enable: 76 | - gci 77 | - gofmt 78 | - gofumpt 79 | - goimports 80 | exclusions: 81 | generated: lax 82 | paths: 83 | - third_party$ 84 | - builtin$ 85 | - examples$ 86 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema: https://goreleaser.com/static/schema.json 2 | 3 | # Visit https://goreleaser.com for documentation on how to customize this behavior. 4 | version: 2 5 | 6 | before: 7 | hooks: 8 | # this is just an example and not a requirement for provider building/publishing 9 | - go mod tidy 10 | builds: 11 | - env: 12 | # goreleaser does not work with CGO, it could also complicate 13 | # usage by users in CI/CD systems like Terraform Cloud where 14 | # they are unable to install libraries. 15 | - CGO_ENABLED=0 16 | mod_timestamp: '{{ .CommitTimestamp }}' 17 | flags: 18 | - -trimpath 19 | ldflags: 20 | - '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}' 21 | goos: 22 | - freebsd 23 | - windows 24 | - linux 25 | - darwin 26 | goarch: 27 | - amd64 28 | - '386' 29 | - arm 30 | - arm64 31 | ignore: 32 | - goos: darwin 33 | goarch: '386' 34 | binary: '{{ .ProjectName }}_v{{ .Version }}' 35 | archives: 36 | - formats: [ 'zip' ] 37 | name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}' 38 | checksum: 39 | extra_files: 40 | - glob: 'terraform-registry-manifest.json' 41 | name_template: '{{ .ProjectName }}_{{ .Version }}_manifest.json' 42 | name_template: '{{ .ProjectName }}_{{ .Version }}_SHA256SUMS' 43 | algorithm: sha256 44 | signs: 45 | - artifacts: checksum 46 | args: 47 | # if you are using this in a GitHub action or some other automated pipeline, you 48 | # need to pass the batch flag to indicate its not interactive. 49 | - "--batch" 50 | - "--local-user" 51 | - "{{ .Env.GPG_FINGERPRINT }}" # set this environment variable for your signing key 52 | - "--output" 53 | - "${signature}" 54 | - "--detach-sign" 55 | - "${artifact}" 56 | release: 57 | extra_files: 58 | - glob: 'terraform-registry-manifest.json' 59 | name_template: '{{ .ProjectName }}_{{ .Version }}_manifest.json' 60 | # If you want to manually examine the release before its live, uncomment this line: 61 | # draft: true 62 | changelog: 63 | disable: true 64 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BINARY_DIR=bin 2 | BINARY_NAME=terraform-provider-hetznerdns 3 | 4 | .PHONY: build testacc test lint generate docs fmt 5 | 6 | build: 7 | mkdir -p $(BINARY_DIR) 8 | go build -o $(BINARY_DIR)/$(BINARY_NAME) 9 | 10 | testacc: 11 | TF_ACC=1 go test -v ./internal/provider -timeout 30m 12 | 13 | test: 14 | go test -v ./... -timeout 30m 15 | 16 | lint: 17 | golangci-lint run ./... 18 | go run github.com/bflad/tfproviderlint/cmd/tfproviderlintx@latest ./... 19 | 20 | generate docs: 21 | go generate ./... 22 | 23 | fmt: 24 | go fmt ./... 25 | -go run mvdan.cc/gofumpt@latest -l -w . 26 | -go run golang.org/x/tools/cmd/goimports@latest -l -w . 27 | -go run github.com/bombsimon/wsl/v4/cmd...@latest -strict-append -test=true -fix ./... 28 | -go run github.com/catenacyber/perfsprint@latest -fix ./... 29 | -go run github.com/bflad/tfproviderlint/cmd/tfproviderlintx@latest -fix ./... 30 | 31 | download: 32 | @echo Download go.mod dependencies 33 | @go mod download 34 | 35 | install-devtools: download 36 | @echo Installing tools from tools.go 37 | @cat tools/tools.go | grep _ | awk -F'"' '{print $$2}' | xargs -tI % go install % 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Terraform Provider for Hetzner DNS 2 | 3 | [![Terraform](https://img.shields.io/badge/Terraform-844FBA.svg?style=for-the-badge&logo=Terraform&logoColor=white)](https://registry.terraform.io/providers/germanbrew/hetznerdns/latest) 4 | [![OpenTofu](https://img.shields.io/badge/OpenTofu-FFDA18.svg?style=for-the-badge&logo=OpenTofu&logoColor=black)](https://github.com/opentofu/registry/blob/main/providers/g/germanbrew/hetznerdns.json) 5 | [![GitHub Release](https://img.shields.io/github/v/release/germanbrew/terraform-provider-hetznerdns?sort=date&display_name=release&style=for-the-badge&logo=github&link=https%3A%2F%2Fgithub.com%2Fgermanbrew%2Fterraform-provider-hetznerdns%2Freleases%2Flatest)](https://github.com/germanbrew/terraform-provider-hetznerdns/releases/latest) 6 | [![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/germanbrew/terraform-provider-hetznerdns/test.yaml?branch=main&style=for-the-badge&logo=github&label=Tests&link=https%3A%2F%2Fgithub.com%2Fgermanbrew%2Fterraform-provider-hetznerdns%2Factions%2Fworkflows%2Ftest.yaml)](https://github.com/germanbrew/terraform-provider-hetznerdns/actions/workflows/test.yaml) 7 | 8 | You can find resources and data sources [documentation](https://registry.terraform.io/providers/germanbrew/hetznerdns/latest/docs) there or [here](docs). 9 | 10 | ## Requirements 11 | 12 | - [Terraform](https://www.terraform.io/downloads.html) > v1.0 13 | 14 | ## Installing and Using this Plugin 15 | 16 | You most likely want to download the provider from [Terraform Registry](https://registry.terraform.io/providers/germanbrew/hetznerdns/latest/docs). 17 | The provider is also published in the [OpenTofu Registry](https://github.com/opentofu/registry/tree/main/providers/g/germanbrew). 18 | 19 | ### Migration Guide 20 | 21 | If you previously used the `timohirt/hetznerdns` provider, you can easily replace the provider in your terraform state 22 | by following our [migration guide in the provider documentation](https://registry.terraform.io/providers/germanbrew/hetznerdns/latest/docs/guides/migration-from-timohirt-hetznerdns). 23 | 24 | ### Using Provider from Terraform Registry (TF >= 1.0) 25 | 26 | This provider is published and available there. If you want to use it, just 27 | add the following to your `terraform.tf`: 28 | 29 | ```terraform 30 | terraform { 31 | required_providers { 32 | hetznerdns = { 33 | source = "germanbrew/hetznerdns" 34 | version = "3.0.0" # Replace with latest version 35 | } 36 | } 37 | required_version = ">= 1.0" 38 | } 39 | ``` 40 | 41 | Then run `terraform init` to download the provider. 42 | 43 | ## Authentication 44 | 45 | Once installed, you have three options to provide the required API token that 46 | is used to authenticate at the Hetzner DNS API. 47 | 48 | ### Enter API Token when needed 49 | 50 | You can enter it every time you run `terraform`. 51 | 52 | ### Configure the Provider to take the API Token from a Variable 53 | 54 | Add the following to your `terraform.tf`: 55 | 56 | ```terraform 57 | variable "hetznerdns_token" {} 58 | 59 | provider "hetznerdns" { 60 | api_token = var.hetznerdns_token 61 | } 62 | ``` 63 | 64 | Now, assign your API token to `hetznerdns_token` in `terraform.tfvars`: 65 | 66 | ```terraform 67 | hetznerdns_token = "kkd993i3kkmm4m4m4" 68 | ``` 69 | 70 | You don't have to enter the API token anymore. 71 | 72 | ### Inject the API Token via the Environment 73 | 74 | Assign the API token to `HETZNER_DNS_TOKEN` env variable. 75 | 76 | ```sh 77 | export HETZNER_DNS_TOKEN= 78 | ``` 79 | 80 | The provider uses this token, and you don't have to enter it anymore. 81 | 82 | ## Credits 83 | 84 | This project is a continuation of [timohirt/terraform-provider-hetznerdns](https://github.com/timohirt/terraform-provider-hetznerdns) 85 | 86 | ## Development 87 | 88 | ### Requirements 89 | 90 | - [Go](https://golang.org/) 1.21 (to build the provider plugin) 91 | - [golangci-lint](https://github.com/golangci/golangci-lint) (to lint code) 92 | - [terraform-plugin-docs](https://github.com/hashicorp/terraform-plugin-docs) (to generate registry documentation) 93 | 94 | ### Install and update development tools 95 | 96 | Run the following command 97 | 98 | ```sh 99 | make install-devtools 100 | ``` 101 | 102 | ### Makefile Commands 103 | 104 | Check the subcommands in our [Makefile](Makefile) for useful dev tools and scripts. 105 | 106 | ### Testing the provider locally 107 | 108 | To test the provider locally: 109 | 110 | 1. Build the provider binary with `make build` 111 | 2. Create a new file `~/.terraform.rc` and point the provider to the absolute **directory** path of the binary file: 112 | ```hcl 113 | provider_installation { 114 | dev_overrides { 115 | "germanbrew/hetznerdns" = "/path/to/your/terraform-provider-hetznerdns/bin/" 116 | } 117 | direct {} 118 | } 119 | ``` 120 | 3. - Set the variable before running terraform commands: 121 | 122 | ```sh 123 | TF_CLI_CONFIG_FILE=~/.terraform.rc terraform plan 124 | ``` 125 | 126 | - Or set the env variable `TF_CLI_CONFIG_FILE` and point it to `~/.terraform.rc`: e.g. 127 | 128 | ```sh 129 | export TF_CLI_CONFIG_FILE=~/.terraform.rc` 130 | ``` 131 | 132 | 4. Now you can just use terraform normally. A warning will appear, that notifies you that you are using an provider override 133 | ``` 134 | Warning: Provider development overrides are in effect 135 | ... 136 | ``` 137 | 5. Unset the env variable if you don't want to use the local provider anymore: 138 | ```sh 139 | unset TF_CLI_CONFIG_FILE 140 | ``` 141 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing and Publishing Provider 2 | 3 | A release of this provider includes publishing the new version 4 | at the Terraform registry. Therefore, a file containing hashes 5 | of the binaries must be signed with my private GPG key. 6 | 7 | Create a new tag and push it to GitHub. This starts a workflow that creates a release. 8 | 9 | ```bash 10 | $ git tag v1.0.17 11 | $ git push --tags 12 | ``` 13 | 14 | -------------------------------------------------------------------------------- /docs/data-sources/nameservers.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "hetznerdns_nameservers Data Source - hetznerdns" 4 | subcategory: "" 5 | description: |- 6 | Provides details about name servers used by Hetzner DNS 7 | --- 8 | 9 | # hetznerdns_nameservers (Data Source) 10 | 11 | Provides details about name servers used by Hetzner DNS 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | data "hetznerdns_nameservers" "authoritative" { 17 | type = "authoritative" 18 | } 19 | 20 | # Not specifying the type will default to authoritative like above 21 | data "hetznerdns_nameservers" "primary" {} 22 | 23 | resource "hetznerdns_record" "mydomain_de-NS" { 24 | for_each = toset(data.hetznerdns_nameservers.primary.ns.*.name) 25 | 26 | zone_id = hetznerdns_zone.mydomain_de.id 27 | name = "@" 28 | type = "NS" 29 | value = each.value 30 | } 31 | ``` 32 | 33 | 34 | ## Schema 35 | 36 | ### Optional 37 | 38 | - `type` (String) Type of name servers to get data from. Default: `authoritative` Possible values: `authoritative`, `secondary`, `konsoleh` 39 | 40 | ### Read-Only 41 | 42 | - `ns` (Attributes Set) Name servers (see [below for nested schema](#nestedatt--ns)) 43 | 44 | 45 | ### Nested Schema for `ns` 46 | 47 | Read-Only: 48 | 49 | - `ipv4` (String) IPv4 address of the name server 50 | - `ipv6` (String) IPv6 address of the name server 51 | - `name` (String) Name of the name server 52 | -------------------------------------------------------------------------------- /docs/data-sources/records.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "hetznerdns_records Data Source - hetznerdns" 4 | subcategory: "" 5 | description: |- 6 | Provides details about all Records of a Hetzner DNS Zone 7 | --- 8 | 9 | # hetznerdns_records (Data Source) 10 | 11 | Provides details about all Records of a Hetzner DNS Zone 12 | 13 | 14 | 15 | 16 | ## Schema 17 | 18 | ### Required 19 | 20 | - `zone_id` (String) ID of the DNS zone to get records from 21 | 22 | ### Optional 23 | 24 | - `timeouts` (Block, Optional) (see [below for nested schema](#nestedblock--timeouts)) 25 | 26 | ### Read-Only 27 | 28 | - `records` (Attributes List) The DNS records of the zone (see [below for nested schema](#nestedatt--records)) 29 | 30 | 31 | ### Nested Schema for `timeouts` 32 | 33 | Optional: 34 | 35 | - `read` (String) [Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of 36 | numbers and unit suffixes, such as "30s" or "2h45m". 37 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m 38 | 39 | 40 | 41 | ### Nested Schema for `records` 42 | 43 | Read-Only: 44 | 45 | - `id` (String) ID of this DNS record 46 | - `name` (String) Name of this DNS record 47 | - `ttl` (Number) Time to live of this record 48 | - `type` (String) Type of this DNS record 49 | - `value` (String) Value of this DNS record 50 | - `zone_id` (String) ID of the DNS zone 51 | -------------------------------------------------------------------------------- /docs/data-sources/zone.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "hetznerdns_zone Data Source - hetznerdns" 4 | subcategory: "" 5 | description: |- 6 | Provides details about a Hetzner DNS Zone 7 | --- 8 | 9 | # hetznerdns_zone (Data Source) 10 | 11 | Provides details about a Hetzner DNS Zone 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | data "hetznerdns_zone" "zone1" { 17 | name = "zone1.online" 18 | } 19 | ``` 20 | 21 | 22 | ## Schema 23 | 24 | ### Required 25 | 26 | - `name` (String) Name of the DNS zone to get data from 27 | 28 | ### Optional 29 | 30 | - `timeouts` (Block, Optional) (see [below for nested schema](#nestedblock--timeouts)) 31 | 32 | ### Read-Only 33 | 34 | - `id` (String) The ID of the DNS zone 35 | - `ns` (List of String) Name Servers of the zone 36 | - `ttl` (Number) Time to live of this zone 37 | 38 | 39 | ### Nested Schema for `timeouts` 40 | 41 | Optional: 42 | 43 | - `read` (String) [Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of 44 | numbers and unit suffixes, such as "30s" or "2h45m". 45 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m 46 | -------------------------------------------------------------------------------- /docs/functions/idna.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "idna function - hetznerdns" 4 | subcategory: "" 5 | description: |- 6 | idna function 7 | --- 8 | 9 | # function: idna 10 | 11 | idna converts a [IDN] domain or domain label to its ASCII form (Punnycode). For example, `provider::hetznerdns::idna("bücher.example.com")` is "xn--bcher-kva.example.com", and `provider::hetznerdns::idna("golang")` is "golang". If an error is encountered it will return an error and a (partially) processed result. 12 | 13 | [IDN]: https://en.wikipedia.org/wiki/Internationalized_domain_name 14 | 15 | ## Example Usage 16 | 17 | ```terraform 18 | resource "hetznerdns_zone" "zone1" { 19 | name = provider::hetznerdns::idna("bücher.example.com") 20 | ttl = 3600 21 | } 22 | ``` 23 | 24 | ## Signature 25 | 26 | 27 | ```text 28 | idna(domain string) string 29 | ``` 30 | 31 | ## Arguments 32 | 33 | 34 | 1. `domain` (String) domain to convert 35 | 36 | -------------------------------------------------------------------------------- /docs/guides/investigating-rate-limits.md: -------------------------------------------------------------------------------- 1 | --- 2 | subcategory: "" 3 | layout: "hetznerdns" 4 | page_title: "Investigating Rate Limits" 5 | description: |- 6 | A Guide on how to investigate and resolve rate limit issues with the Hetzner DNS API 7 | --- 8 | 9 | # How to investigate and resolve rate limit issues with the Hetzner DNS API 10 | 11 | Hetzner DNS API has a default rate limit of 300 requests per minute. If you're getting a rate limit error, you can investigate and resolve it by following these steps: 12 | 13 | 1. If you're getting a rate limit error like below, try to increase the provider config [`max_retries`](https://registry.terraform.io/providers/germanbrew/hetznerdns/latest/docs#max_retries-1) to a higher value like `10`: 14 | ```bash 15 | Error: API Error 16 | read record: error getting record 3c21...75fb: API returned HTTP 429 Too Many Requests error: rate limit exceeded 17 | ``` 18 | 19 | 2. You can view the ratelimit usage in the terraform logs by running `terraform plan` or `apply` with the `TF_LOG` environment variable set to `DEBUG`. Since the output is probably too long for the terminal, you might want to redirect it to some file like this: 20 | ```bash 21 | TF_LOG=DEBUG TF_LOG_PATH=tf_log_debug.log terraform plan 22 | ``` 23 | The http client will then log the entire http response including all headers. In the headers you will find rate limit details: 24 | 25 | | Header Name | Example Value | 26 | |------------------------------|---------------| 27 | | x-ratelimit-remaining-minute | 296 | 28 | | x-ratelimit-limit-minute | 300 | 29 | | ratelimit-remaining | 296 | 30 | | ratelimit-limit | 300 | 31 | | ratelimit-reset | 50 (seconds) | 32 | 33 | 3. If you need to increase the rate limit, you can contact Hetzner Support to request a higher rate limit for your account. 34 | -------------------------------------------------------------------------------- /docs/guides/migration-from-timohirt-hetznerdns.md: -------------------------------------------------------------------------------- 1 | --- 2 | subcategory: "" 3 | layout: "hetznerdns" 4 | page_title: "Migration from timohirt/hetznerdns" 5 | description: |- 6 | A Guide on how to migrate your Terraform State from timohirt/hetznerdns to germanbrew/hetznerdns 7 | --- 8 | 9 | # How to migrate from timohirt/hetznerdns to germanbrew/hetznerdns 10 | 11 | If you previously used the `timohirt/hetznerdns` provider, you can easily replace the provider in your terraform state by following these steps: 12 | 13 | ~> **NOTE:** It is recommended to backup your Terraform state before migrating by running this command: `terraform state pull > terraform-state-backup.json` 14 | 15 | ## Migration Steps 16 | 17 | 1. In your `terraform` -> `required_providers` config, replace the provider config: 18 | 19 | ```diff 20 | hetznerdns = { 21 | - source = "timohirt/hetznerdns" 22 | + source = "germanbrew/hetznerdns" 23 | - version = "2.2.0" 24 | + version = "3.0.0" # Replace with latest version 25 | } 26 | ``` 27 | 28 | 2. If you have `apitoken` defined inside you provider config, replace it with `api_token`. The environment variable is now called `HETZNER_DNS_TOKEN` instead of `HETZNER_DNS_API_TOKEN`. 29 | Also see our [Docs Overview](https://registry.terraform.io/providers/germanbrew/hetznerdns/latest/docs#schema), as we have more configuration options for you to choose. 30 | 31 | ```diff 32 | provider "hetznerdns" {" 33 | - apitoken = "token" 34 | + api_token = "token" 35 | } 36 | ``` 37 | 38 | 3. Install the new provider and replace it in the state: 39 | 40 | ```sh 41 | terraform init 42 | terraform state replace-provider timohirt/hetznerdns germanbrew/hetznerdns 43 | ``` 44 | 45 | 4. Our provider automatically reformats TXT record values into the correct format ([RFC4408](https://datatracker.ietf.org/doc/html/rfc4408#section-3.1.3)). 46 | This means you don't need to escape the values yourself with `jsonencode()` or other functions to split the records every 255 bytes. 47 | You can disable this feature by specifying `enable_txt_formatter = false` in your provider config or setting the env var `HETZNER_DNS_ENABLE_TXT_FORMATTER=false` 48 | 49 | 5. Test if the migration was successful by running `terraform plan` and checking the output for any errors. 50 | 51 | 6. If you're getting a rate limit error like below, follow the [Investigating Rate Limits](https://registry.terraform.io/providers/germanbrew/hetznerdns/latest/docs/guides/investigating-rate-limits) guide: 52 | ```bash 53 | Error: API Error 54 | read record: error getting record 3c2...: API returned HTTP 429 Too Many Requests error: rate limit exceeded 55 | ``` 56 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "hetznerdns Provider" 4 | subcategory: "" 5 | description: |- 6 | This providers helps you automate management of DNS zones and records at Hetzner DNS. 7 | --- 8 | 9 | # hetznerdns Provider 10 | 11 | This providers helps you automate management of DNS zones and records at Hetzner DNS. 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | provider "hetznerdns" { 17 | api_token = "" 18 | } 19 | 20 | data "hetznerdns_zone" "dns_zone" { 21 | name = "example.com" 22 | } 23 | 24 | data "hcloud_server" "web" { 25 | name = "web1" 26 | } 27 | 28 | resource "hetznerdns_record" "web" { 29 | zone_id = data.hetznerdns_zone.dns_zone.id 30 | name = "www" 31 | value = hcloud_server.web.ipv4_address 32 | type = "A" 33 | ttl = 60 34 | } 35 | ``` 36 | 37 | 38 | ## Schema 39 | 40 | ### Optional 41 | 42 | - `api_token` (String, Sensitive) The Hetzner DNS API token. You can pass it using the env variable `HETZNER_DNS_TOKEN` as well. The old env variable `HETZNER_DNS_API_TOKEN` is deprecated and will be removed in a future release. 43 | - `enable_ip_validation` (Boolean) `Default: true` Toggles the validation of IP addresses in A and AAAA records. You can pass it using the env variable `HETZNER_DNS_ENABLE_IP_VALIDATION` as well. 44 | - `enable_txt_formatter` (Boolean) `Default: true` Toggles the automatic formatter for TXT record values. Values greater than 255 bytes get split into multiple quoted chunks ([RFC4408](https://datatracker.ietf.org/doc/html/rfc4408#section-3.1.3)). You can pass it using the env variable `HETZNER_DNS_ENABLE_TXT_FORMATTER` as well. 45 | - `max_retries` (Number) `Default: 1` The maximum number of retries to perform when an API request fails. You can pass it using the env variable `HETZNER_DNS_MAX_RETRIES` as well. 46 | -------------------------------------------------------------------------------- /docs/resources/primary_server.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "hetznerdns_primary_server Resource - hetznerdns" 4 | subcategory: "" 5 | description: |- 6 | Configure primary server for a domain 7 | --- 8 | 9 | # hetznerdns_primary_server (Resource) 10 | 11 | Configure primary server for a domain 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | resource "hetznerdns_zone" "zone1" { 17 | name = "zone1.online" 18 | ttl = 3600 19 | } 20 | 21 | resource "hetznerdns_primary_server" "ps1" { 22 | zone_id = hetznerdns_zone.zone1.id 23 | address = "1.1.1.1" 24 | port = 53 25 | } 26 | ``` 27 | 28 | 29 | ## Schema 30 | 31 | ### Required 32 | 33 | - `address` (String) Address of the primary server. 34 | - `port` (Number) Port of the primary server. 35 | - `zone_id` (String) Zone identifier 36 | 37 | ### Optional 38 | 39 | - `timeouts` (Block, Optional) (see [below for nested schema](#nestedblock--timeouts)) 40 | 41 | ### Read-Only 42 | 43 | - `id` (String) Zone identifier 44 | 45 | 46 | ### Nested Schema for `timeouts` 47 | 48 | Optional: 49 | 50 | - `create` (String) [Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of 51 | numbers and unit suffixes, such as "30s" or "2h45m". 52 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m 53 | - `delete` (String) [Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of 54 | numbers and unit suffixes, such as "30s" or "2h45m". 55 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m 56 | - `read` (String) [Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of 57 | numbers and unit suffixes, such as "30s" or "2h45m". 58 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m 59 | - `update` (String) [Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of 60 | numbers and unit suffixes, such as "30s" or "2h45m". 61 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m 62 | 63 | ## Import 64 | 65 | Import is supported using the following syntax: 66 | 67 | ```shell 68 | terraform import hetznerdns_zone.zone1 rMu2waTJPbHr4 69 | ``` 70 | -------------------------------------------------------------------------------- /docs/resources/record.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "hetznerdns_record Resource - hetznerdns" 4 | subcategory: "" 5 | description: |- 6 | Provides a Hetzner DNS Zone resource to create, update and delete DNS Zones. 7 | --- 8 | 9 | # hetznerdns_record (Resource) 10 | 11 | Provides a Hetzner DNS Zone resource to create, update and delete DNS Zones. 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | # Basic Usage 17 | 18 | data "hetznerdns_zone" "example" { 19 | name = "example.com" 20 | } 21 | 22 | # Handle root (example.com) 23 | resource "hetznerdns_record" "example_com_root" { 24 | zone_id = data.hetznerdns_zone.example.id 25 | name = "@" 26 | value = "1.2.3.4" 27 | type = "A" 28 | # You only need to set a TTL if it's different from the zone's TTL above 29 | ttl = 300 30 | } 31 | 32 | # Handle wildcard subdomain (*.example.com) 33 | resource "hetznerdns_record" "all_example_com" { 34 | zone_id = data.hetznerdns_zone.example.id 35 | name = "*" 36 | value = "1.2.3.4" 37 | type = "A" 38 | } 39 | 40 | # Handle specific subdomain (books.example.com) 41 | resource "hetznerdns_record" "books_example_com" { 42 | zone_id = data.hetznerdns_zone.example.id 43 | name = "books" 44 | value = "1.2.3.4" 45 | type = "A" 46 | } 47 | 48 | # Handle email (MX record with priority 10) 49 | resource "hetznerdns_record" "example_com_email" { 50 | zone_id = data.hetznerdns_zone.example.id 51 | name = "@" 52 | value = "10 mail.example.com" 53 | type = "MX" 54 | } 55 | 56 | # SPF record 57 | resource "hetznerdns_record" "example_com_spf" { 58 | zone_id = data.hetznerdns_zone.example.id 59 | name = "@" 60 | value = "v=spf1 ip4:1.2.3.4 -all" 61 | type = "TXT" 62 | } 63 | 64 | # SRV record 65 | resource "hetznerdns_record" "example_com_srv" { 66 | zone_id = data.hetznerdns_zone.example.id 67 | name = "_ldap._tcp" 68 | value = "10 0 389 ldap.example.com." 69 | type = "SRV" 70 | ttl = 3600 71 | } 72 | ``` 73 | 74 | 75 | ## Schema 76 | 77 | ### Required 78 | 79 | - `name` (String) Name of the DNS record to create 80 | - `type` (String) Type of this DNS record ([See supported types](https://docs.hetzner.com/dns-console/dns/general/supported-dns-record-types/)) 81 | - `value` (String) The value of the record (e.g. `192.168.1.1`) 82 | - `zone_id` (String) ID of the DNS zone to create the record in. 83 | 84 | ### Optional 85 | 86 | - `timeouts` (Block, Optional) (see [below for nested schema](#nestedblock--timeouts)) 87 | - `ttl` (Number) Time to live of this record 88 | 89 | ### Read-Only 90 | 91 | - `id` (String) Zone identifier 92 | 93 | 94 | ### Nested Schema for `timeouts` 95 | 96 | Optional: 97 | 98 | - `create` (String) [Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of 99 | numbers and unit suffixes, such as "30s" or "2h45m". 100 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m 101 | - `delete` (String) [Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of 102 | numbers and unit suffixes, such as "30s" or "2h45m". 103 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m 104 | - `read` (String) [Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of 105 | numbers and unit suffixes, such as "30s" or "2h45m". 106 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m 107 | - `update` (String) [Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of 108 | numbers and unit suffixes, such as "30s" or "2h45m". 109 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m 110 | 111 | ## Import 112 | 113 | Import is supported using the following syntax: 114 | 115 | ```shell 116 | # A Record can be imported using its `id`. Use the API to get all records of 117 | # a zone and then copy the id. 118 | # 119 | # curl "https://dns.hetzner.com/api/v1/records" \ 120 | # -H "Auth-API-Token: $HETZNER_DNS_TOKEN" | jq . 121 | # 122 | # { 123 | # "records": [ 124 | # { 125 | # "id": "3d60921a49eb384b6335766a", 126 | # "type": "TXT", 127 | # "name": "google._domainkey", 128 | # "value": "\"anything:with:param\"", 129 | # "zone_id": "rMu2waTJPbHr4", 130 | # "created": "2020-08-18 19:11:02.237 +0000 UTC", 131 | # "modified": "2020-08-28 19:51:41.275 +0000 UTC" 132 | # }, 133 | # { 134 | # "id": "ed2416cb6bc8a8055b22222", 135 | # "type": "A", 136 | # "name": "www", 137 | # "value": "1.1.1.1", 138 | # "zone_id": "rMu2waTJPbHr4", 139 | # "created": "2020-08-27 20:55:38.745 +0000 UTC", 140 | # "modified": "2020-08-27 20:55:38.745 +0000 UTC" 141 | # } 142 | # ] 143 | # } 144 | 145 | terraform import hetznerdns_record.dkim_google 3d60921a49eb384b6335766a 146 | ``` 147 | -------------------------------------------------------------------------------- /docs/resources/zone.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "hetznerdns_zone Resource - hetznerdns" 4 | subcategory: "" 5 | description: |- 6 | Provides a Hetzner DNS Zone resource to create, update and delete DNS Zones. 7 | --- 8 | 9 | # hetznerdns_zone (Resource) 10 | 11 | Provides a Hetzner DNS Zone resource to create, update and delete DNS Zones. 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | ## Simple Example 17 | 18 | resource "hetznerdns_zone" "example_com" { 19 | name = "example.com" 20 | ttl = 3600 21 | } 22 | 23 | ## DNS Zone Delegation 24 | 25 | # Subdomain Zone 26 | resource "hetznerdns_zone" "subdomain_example_com" { 27 | name = "subdomain.example.com" 28 | ttl = 300 29 | } 30 | 31 | # Primary Domain Zone 32 | resource "hetznerdns_zone" "example_com" { 33 | name = "example.com" 34 | ttl = 300 35 | } 36 | 37 | # Nameserver Records for the Subdomain 38 | ## This block dynamically creates NS records in the primary domain zone to delegate authority to the subdomain. 39 | ## Be aware that the zone must be already created before creating the NS records, otherwise the creation will fail. 40 | ## Alternatively, you can use the `hetznerdns_nameserver` data source to get the nameservers and create the NS records. 41 | resource "hetznerdns_record" "example_com-NS" { 42 | for_each = toset(hetznerdns_zone.mydomain_de.ns) 43 | 44 | zone_id = hetznerdns_zone.example_com.id 45 | name = "@" 46 | type = "NS" 47 | value = each.value 48 | } 49 | ``` 50 | 51 | 52 | ## Schema 53 | 54 | ### Required 55 | 56 | - `name` (String) Name of the DNS zone to create. Must be a valid domain with top level domain. Meaning `.de` or `.io`. Don't include sub domains on this level. So, no `sub..io`. The Hetzner API rejects attempts to create a zone with a sub domain name.Use a record to create the sub domain. 57 | 58 | ### Optional 59 | 60 | - `timeouts` (Block, Optional) (see [below for nested schema](#nestedblock--timeouts)) 61 | - `ttl` (Number) Time to live of this zone 62 | 63 | ### Read-Only 64 | 65 | - `id` (String) Zone identifier 66 | - `ns` (List of String) Name Servers of the zone 67 | 68 | 69 | ### Nested Schema for `timeouts` 70 | 71 | Optional: 72 | 73 | - `create` (String) [Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of 74 | numbers and unit suffixes, such as "30s" or "2h45m". 75 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m 76 | - `delete` (String) [Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of 77 | numbers and unit suffixes, such as "30s" or "2h45m". 78 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m 79 | - `read` (String) [Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of 80 | numbers and unit suffixes, such as "30s" or "2h45m". 81 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m 82 | - `update` (String) [Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of 83 | numbers and unit suffixes, such as "30s" or "2h45m". 84 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m 85 | 86 | ## Import 87 | 88 | Import is supported using the following syntax: 89 | 90 | ```shell 91 | terraform import hetznerdns_zone.zone1 rMu2waTJPbHr4 92 | ``` 93 | -------------------------------------------------------------------------------- /examples/data-sources/hetznerdns_nameservers/data-source.tf: -------------------------------------------------------------------------------- 1 | data "hetznerdns_nameservers" "authoritative" { 2 | type = "authoritative" 3 | } 4 | 5 | # Not specifying the type will default to authoritative like above 6 | data "hetznerdns_nameservers" "primary" {} 7 | 8 | resource "hetznerdns_record" "mydomain_de-NS" { 9 | for_each = toset(data.hetznerdns_nameservers.primary.ns.*.name) 10 | 11 | zone_id = hetznerdns_zone.mydomain_de.id 12 | name = "@" 13 | type = "NS" 14 | value = each.value 15 | } 16 | -------------------------------------------------------------------------------- /examples/data-sources/hetznerdns_record/data-source.tf: -------------------------------------------------------------------------------- 1 | data "hetznerdns_zone" "zone1" { 2 | name = "zone1.online" 3 | } 4 | 5 | data "hetznerdns_records" "zone1" { 6 | zone_id = data.hetznerdns_zone.zone1.id 7 | } 8 | 9 | output "zone1_records" { 10 | value = data.hetznerdns_records.zone1.records 11 | } 12 | -------------------------------------------------------------------------------- /examples/data-sources/hetznerdns_zone/data-source.tf: -------------------------------------------------------------------------------- 1 | data "hetznerdns_zone" "zone1" { 2 | name = "zone1.online" 3 | } -------------------------------------------------------------------------------- /examples/functions/idna/function.tf: -------------------------------------------------------------------------------- 1 | resource "hetznerdns_zone" "zone1" { 2 | name = provider::hetznerdns::idna("bücher.example.com") 3 | ttl = 3600 4 | } -------------------------------------------------------------------------------- /examples/provider/provider.tf: -------------------------------------------------------------------------------- 1 | provider "hetznerdns" { 2 | api_token = "" 3 | } 4 | 5 | data "hetznerdns_zone" "dns_zone" { 6 | name = "example.com" 7 | } 8 | 9 | data "hcloud_server" "web" { 10 | name = "web1" 11 | } 12 | 13 | resource "hetznerdns_record" "web" { 14 | zone_id = data.hetznerdns_zone.dns_zone.id 15 | name = "www" 16 | value = hcloud_server.web.ipv4_address 17 | type = "A" 18 | ttl = 60 19 | } 20 | -------------------------------------------------------------------------------- /examples/resources/hetznerdns_primary_server/import.sh: -------------------------------------------------------------------------------- 1 | terraform import hetznerdns_zone.zone1 rMu2waTJPbHr4 -------------------------------------------------------------------------------- /examples/resources/hetznerdns_primary_server/resource.tf: -------------------------------------------------------------------------------- 1 | resource "hetznerdns_zone" "zone1" { 2 | name = "zone1.online" 3 | ttl = 3600 4 | } 5 | 6 | resource "hetznerdns_primary_server" "ps1" { 7 | zone_id = hetznerdns_zone.zone1.id 8 | address = "1.1.1.1" 9 | port = 53 10 | } -------------------------------------------------------------------------------- /examples/resources/hetznerdns_record/import.sh: -------------------------------------------------------------------------------- 1 | # A Record can be imported using its `id`. Use the API to get all records of 2 | # a zone and then copy the id. 3 | # 4 | # curl "https://dns.hetzner.com/api/v1/records" \ 5 | # -H "Auth-API-Token: $HETZNER_DNS_TOKEN" | jq . 6 | # 7 | # { 8 | # "records": [ 9 | # { 10 | # "id": "3d60921a49eb384b6335766a", 11 | # "type": "TXT", 12 | # "name": "google._domainkey", 13 | # "value": "\"anything:with:param\"", 14 | # "zone_id": "rMu2waTJPbHr4", 15 | # "created": "2020-08-18 19:11:02.237 +0000 UTC", 16 | # "modified": "2020-08-28 19:51:41.275 +0000 UTC" 17 | # }, 18 | # { 19 | # "id": "ed2416cb6bc8a8055b22222", 20 | # "type": "A", 21 | # "name": "www", 22 | # "value": "1.1.1.1", 23 | # "zone_id": "rMu2waTJPbHr4", 24 | # "created": "2020-08-27 20:55:38.745 +0000 UTC", 25 | # "modified": "2020-08-27 20:55:38.745 +0000 UTC" 26 | # } 27 | # ] 28 | # } 29 | 30 | terraform import hetznerdns_record.dkim_google 3d60921a49eb384b6335766a 31 | -------------------------------------------------------------------------------- /examples/resources/hetznerdns_record/resource.tf: -------------------------------------------------------------------------------- 1 | # Basic Usage 2 | 3 | data "hetznerdns_zone" "example" { 4 | name = "example.com" 5 | } 6 | 7 | # Handle root (example.com) 8 | resource "hetznerdns_record" "example_com_root" { 9 | zone_id = data.hetznerdns_zone.example.id 10 | name = "@" 11 | value = "1.2.3.4" 12 | type = "A" 13 | # You only need to set a TTL if it's different from the zone's TTL above 14 | ttl = 300 15 | } 16 | 17 | # Handle wildcard subdomain (*.example.com) 18 | resource "hetznerdns_record" "all_example_com" { 19 | zone_id = data.hetznerdns_zone.example.id 20 | name = "*" 21 | value = "1.2.3.4" 22 | type = "A" 23 | } 24 | 25 | # Handle specific subdomain (books.example.com) 26 | resource "hetznerdns_record" "books_example_com" { 27 | zone_id = data.hetznerdns_zone.example.id 28 | name = "books" 29 | value = "1.2.3.4" 30 | type = "A" 31 | } 32 | 33 | # Handle email (MX record with priority 10) 34 | resource "hetznerdns_record" "example_com_email" { 35 | zone_id = data.hetznerdns_zone.example.id 36 | name = "@" 37 | value = "10 mail.example.com" 38 | type = "MX" 39 | } 40 | 41 | # SPF record 42 | resource "hetznerdns_record" "example_com_spf" { 43 | zone_id = data.hetznerdns_zone.example.id 44 | name = "@" 45 | value = "v=spf1 ip4:1.2.3.4 -all" 46 | type = "TXT" 47 | } 48 | 49 | # SRV record 50 | resource "hetznerdns_record" "example_com_srv" { 51 | zone_id = data.hetznerdns_zone.example.id 52 | name = "_ldap._tcp" 53 | value = "10 0 389 ldap.example.com." 54 | type = "SRV" 55 | ttl = 3600 56 | } -------------------------------------------------------------------------------- /examples/resources/hetznerdns_zone/import.sh: -------------------------------------------------------------------------------- 1 | terraform import hetznerdns_zone.zone1 rMu2waTJPbHr4 -------------------------------------------------------------------------------- /examples/resources/hetznerdns_zone/resource.tf: -------------------------------------------------------------------------------- 1 | ## Simple Example 2 | 3 | resource "hetznerdns_zone" "example_com" { 4 | name = "example.com" 5 | ttl = 3600 6 | } 7 | 8 | ## DNS Zone Delegation 9 | 10 | # Subdomain Zone 11 | resource "hetznerdns_zone" "subdomain_example_com" { 12 | name = "subdomain.example.com" 13 | ttl = 300 14 | } 15 | 16 | # Primary Domain Zone 17 | resource "hetznerdns_zone" "example_com" { 18 | name = "example.com" 19 | ttl = 300 20 | } 21 | 22 | # Nameserver Records for the Subdomain 23 | ## This block dynamically creates NS records in the primary domain zone to delegate authority to the subdomain. 24 | ## Be aware that the zone must be already created before creating the NS records, otherwise the creation will fail. 25 | ## Alternatively, you can use the `hetznerdns_nameserver` data source to get the nameservers and create the NS records. 26 | resource "hetznerdns_record" "example_com-NS" { 27 | for_each = toset(hetznerdns_zone.mydomain_de.ns) 28 | 29 | zone_id = hetznerdns_zone.example_com.id 30 | name = "@" 31 | type = "NS" 32 | value = each.value 33 | } 34 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/germanbrew/terraform-provider-hetznerdns 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.3 6 | 7 | require ( 8 | github.com/golangci/golangci-lint/v2 v2.1.6 9 | github.com/hashicorp/terraform-plugin-docs v0.21.0 10 | github.com/hashicorp/terraform-plugin-framework v1.15.0 11 | github.com/hashicorp/terraform-plugin-framework-timeouts v0.5.0 12 | github.com/hashicorp/terraform-plugin-framework-validators v0.18.0 13 | github.com/hashicorp/terraform-plugin-go v0.28.0 14 | github.com/hashicorp/terraform-plugin-log v0.9.0 15 | github.com/hashicorp/terraform-plugin-sdk/v2 v2.37.0 16 | github.com/hashicorp/terraform-plugin-testing v1.13.1 17 | github.com/stretchr/testify v1.10.0 18 | golang.org/x/net v0.40.0 19 | ) 20 | 21 | require ( 22 | 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect 23 | 4d63.com/gochecknoglobals v0.2.2 // indirect 24 | github.com/4meepo/tagalign v1.4.2 // indirect 25 | github.com/Abirdcfly/dupword v0.1.3 // indirect 26 | github.com/Antonboom/errname v1.1.0 // indirect 27 | github.com/Antonboom/nilnil v1.1.0 // indirect 28 | github.com/Antonboom/testifylint v1.6.1 // indirect 29 | github.com/BurntSushi/toml v1.5.0 // indirect 30 | github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 // indirect 31 | github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 // indirect 32 | github.com/Kunde21/markdownfmt/v3 v3.1.0 // indirect 33 | github.com/Masterminds/goutils v1.1.1 // indirect 34 | github.com/Masterminds/semver/v3 v3.3.1 // indirect 35 | github.com/Masterminds/sprig/v3 v3.2.3 // indirect 36 | github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect 37 | github.com/ProtonMail/go-crypto v1.1.6 // indirect 38 | github.com/agext/levenshtein v1.2.2 // indirect 39 | github.com/alecthomas/chroma/v2 v2.17.2 // indirect 40 | github.com/alecthomas/go-check-sumtype v0.3.1 // indirect 41 | github.com/alexkohler/nakedret/v2 v2.0.6 // indirect 42 | github.com/alexkohler/prealloc v1.0.0 // indirect 43 | github.com/alingse/asasalint v0.0.11 // indirect 44 | github.com/alingse/nilnesserr v0.2.0 // indirect 45 | github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect 46 | github.com/armon/go-radix v1.0.0 // indirect 47 | github.com/ashanbrown/forbidigo v1.6.0 // indirect 48 | github.com/ashanbrown/makezero v1.2.0 // indirect 49 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 50 | github.com/beorn7/perks v1.0.1 // indirect 51 | github.com/bgentry/speakeasy v0.1.0 // indirect 52 | github.com/bkielbasa/cyclop v1.2.3 // indirect 53 | github.com/blizzy78/varnamelen v0.8.0 // indirect 54 | github.com/bmatcuk/doublestar/v4 v4.8.1 // indirect 55 | github.com/bombsimon/wsl/v4 v4.7.0 // indirect 56 | github.com/breml/bidichk v0.3.3 // indirect 57 | github.com/breml/errchkjson v0.4.1 // indirect 58 | github.com/butuzov/ireturn v0.4.0 // indirect 59 | github.com/butuzov/mirror v1.3.0 // indirect 60 | github.com/catenacyber/perfsprint v0.9.1 // indirect 61 | github.com/ccojocar/zxcvbn-go v1.0.2 // indirect 62 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 63 | github.com/charithe/durationcheck v0.0.10 // indirect 64 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 65 | github.com/charmbracelet/lipgloss v1.1.0 // indirect 66 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 67 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 68 | github.com/charmbracelet/x/term v0.2.1 // indirect 69 | github.com/chavacava/garif v0.1.0 // indirect 70 | github.com/ckaznocha/intrange v0.3.1 // indirect 71 | github.com/cloudflare/circl v1.6.0 // indirect 72 | github.com/curioswitch/go-reassign v0.3.0 // indirect 73 | github.com/daixiang0/gci v0.13.6 // indirect 74 | github.com/dave/dst v0.27.3 // indirect 75 | github.com/davecgh/go-spew v1.1.1 // indirect 76 | github.com/denis-tingaikin/go-header v0.5.0 // indirect 77 | github.com/dlclark/regexp2 v1.11.5 // indirect 78 | github.com/ettle/strcase v0.2.0 // indirect 79 | github.com/fatih/color v1.18.0 // indirect 80 | github.com/fatih/structtag v1.2.0 // indirect 81 | github.com/firefart/nonamedreturns v1.0.6 // indirect 82 | github.com/fsnotify/fsnotify v1.5.4 // indirect 83 | github.com/fzipp/gocyclo v0.6.0 // indirect 84 | github.com/ghostiam/protogetter v0.3.15 // indirect 85 | github.com/go-critic/go-critic v0.13.0 // indirect 86 | github.com/go-toolsmith/astcast v1.1.0 // indirect 87 | github.com/go-toolsmith/astcopy v1.1.0 // indirect 88 | github.com/go-toolsmith/astequal v1.2.0 // indirect 89 | github.com/go-toolsmith/astfmt v1.1.0 // indirect 90 | github.com/go-toolsmith/astp v1.1.0 // indirect 91 | github.com/go-toolsmith/strparse v1.1.0 // indirect 92 | github.com/go-toolsmith/typep v1.1.0 // indirect 93 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 94 | github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect 95 | github.com/gobwas/glob v0.2.3 // indirect 96 | github.com/gofrs/flock v0.12.1 // indirect 97 | github.com/golang/protobuf v1.5.4 // indirect 98 | github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect 99 | github.com/golangci/go-printf-func-name v0.1.0 // indirect 100 | github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect 101 | github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95 // indirect 102 | github.com/golangci/misspell v0.6.0 // indirect 103 | github.com/golangci/plugin-module-register v0.1.1 // indirect 104 | github.com/golangci/revgrep v0.8.0 // indirect 105 | github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect 106 | github.com/google/go-cmp v0.7.0 // indirect 107 | github.com/google/uuid v1.6.0 // indirect 108 | github.com/gordonklaus/ineffassign v0.1.0 // indirect 109 | github.com/gostaticanalysis/analysisutil v0.7.1 // indirect 110 | github.com/gostaticanalysis/comment v1.5.0 // indirect 111 | github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect 112 | github.com/gostaticanalysis/nilerr v0.1.1 // indirect 113 | github.com/hashicorp/cli v1.1.7 // indirect 114 | github.com/hashicorp/errwrap v1.1.0 // indirect 115 | github.com/hashicorp/go-checkpoint v0.5.0 // indirect 116 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 117 | github.com/hashicorp/go-cty v1.5.0 // indirect 118 | github.com/hashicorp/go-hclog v1.6.3 // indirect 119 | github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect 120 | github.com/hashicorp/go-multierror v1.1.1 // indirect 121 | github.com/hashicorp/go-plugin v1.6.3 // indirect 122 | github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 123 | github.com/hashicorp/go-uuid v1.0.3 // indirect 124 | github.com/hashicorp/go-version v1.7.0 // indirect 125 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 126 | github.com/hashicorp/hc-install v0.9.2 // indirect 127 | github.com/hashicorp/hcl v1.0.0 // indirect 128 | github.com/hashicorp/hcl/v2 v2.23.0 // indirect 129 | github.com/hashicorp/logutils v1.0.0 // indirect 130 | github.com/hashicorp/terraform-exec v0.23.0 // indirect 131 | github.com/hashicorp/terraform-json v0.25.0 // indirect 132 | github.com/hashicorp/terraform-registry-address v0.2.5 // indirect 133 | github.com/hashicorp/terraform-svchost v0.1.1 // indirect 134 | github.com/hashicorp/yamux v0.1.1 // indirect 135 | github.com/hexops/gotextdiff v1.0.3 // indirect 136 | github.com/huandu/xstrings v1.3.3 // indirect 137 | github.com/imdario/mergo v0.3.15 // indirect 138 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 139 | github.com/jgautheron/goconst v1.8.1 // indirect 140 | github.com/jingyugao/rowserrcheck v1.1.1 // indirect 141 | github.com/jjti/go-spancheck v0.6.4 // indirect 142 | github.com/julz/importas v0.2.0 // indirect 143 | github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect 144 | github.com/kisielk/errcheck v1.9.0 // indirect 145 | github.com/kkHAIKE/contextcheck v1.1.6 // indirect 146 | github.com/kulti/thelper v0.6.3 // indirect 147 | github.com/kunwardeep/paralleltest v1.0.14 // indirect 148 | github.com/lasiar/canonicalheader v1.1.2 // indirect 149 | github.com/ldez/exptostd v0.4.3 // indirect 150 | github.com/ldez/gomoddirectives v0.6.1 // indirect 151 | github.com/ldez/grignotin v0.9.0 // indirect 152 | github.com/ldez/tagliatelle v0.7.1 // indirect 153 | github.com/ldez/usetesting v0.4.3 // indirect 154 | github.com/leonklingele/grouper v1.1.2 // indirect 155 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 156 | github.com/macabu/inamedparam v0.2.0 // indirect 157 | github.com/magiconair/properties v1.8.6 // indirect 158 | github.com/manuelarte/funcorder v0.2.1 // indirect 159 | github.com/maratori/testableexamples v1.0.0 // indirect 160 | github.com/maratori/testpackage v1.1.1 // indirect 161 | github.com/matoous/godox v1.1.0 // indirect 162 | github.com/mattn/go-colorable v0.1.14 // indirect 163 | github.com/mattn/go-isatty v0.0.20 // indirect 164 | github.com/mattn/go-runewidth v0.0.16 // indirect 165 | github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect 166 | github.com/mgechev/revive v1.9.0 // indirect 167 | github.com/mitchellh/copystructure v1.2.0 // indirect 168 | github.com/mitchellh/go-homedir v1.1.0 // indirect 169 | github.com/mitchellh/go-testing-interface v1.14.1 // indirect 170 | github.com/mitchellh/go-wordwrap v1.0.0 // indirect 171 | github.com/mitchellh/mapstructure v1.5.0 // indirect 172 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 173 | github.com/moricho/tparallel v0.3.2 // indirect 174 | github.com/muesli/termenv v0.16.0 // indirect 175 | github.com/nakabonne/nestif v0.3.1 // indirect 176 | github.com/nishanths/exhaustive v0.12.0 // indirect 177 | github.com/nishanths/predeclared v0.2.2 // indirect 178 | github.com/nunnatsa/ginkgolinter v0.19.1 // indirect 179 | github.com/oklog/run v1.1.0 // indirect 180 | github.com/olekukonko/tablewriter v0.0.5 // indirect 181 | github.com/pelletier/go-toml v1.9.5 // indirect 182 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 183 | github.com/pmezard/go-difflib v1.0.0 // indirect 184 | github.com/polyfloyd/go-errorlint v1.8.0 // indirect 185 | github.com/posener/complete v1.2.3 // indirect 186 | github.com/prometheus/client_golang v1.12.1 // indirect 187 | github.com/prometheus/client_model v0.2.0 // indirect 188 | github.com/prometheus/common v0.32.1 // indirect 189 | github.com/prometheus/procfs v0.7.3 // indirect 190 | github.com/quasilyte/go-ruleguard v0.4.4 // indirect 191 | github.com/quasilyte/go-ruleguard/dsl v0.3.22 // indirect 192 | github.com/quasilyte/gogrep v0.5.0 // indirect 193 | github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect 194 | github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect 195 | github.com/raeperd/recvcheck v0.2.0 // indirect 196 | github.com/rivo/uniseg v0.4.7 // indirect 197 | github.com/rogpeppe/go-internal v1.14.1 // indirect 198 | github.com/ryancurrah/gomodguard v1.4.1 // indirect 199 | github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect 200 | github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect 201 | github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect 202 | github.com/sashamelentyev/interfacebloat v1.1.0 // indirect 203 | github.com/sashamelentyev/usestdlibvars v1.28.0 // indirect 204 | github.com/securego/gosec/v2 v2.22.3 // indirect 205 | github.com/shopspring/decimal v1.3.1 // indirect 206 | github.com/sirupsen/logrus v1.9.3 // indirect 207 | github.com/sivchari/containedctx v1.0.3 // indirect 208 | github.com/sonatard/noctx v0.1.0 // indirect 209 | github.com/sourcegraph/go-diff v0.7.0 // indirect 210 | github.com/spf13/afero v1.14.0 // indirect 211 | github.com/spf13/cast v1.5.0 // indirect 212 | github.com/spf13/cobra v1.9.1 // indirect 213 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 214 | github.com/spf13/pflag v1.0.6 // indirect 215 | github.com/spf13/viper v1.12.0 // indirect 216 | github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect 217 | github.com/stbenjam/no-sprintf-host-port v0.2.0 // indirect 218 | github.com/stretchr/objx v0.5.2 // indirect 219 | github.com/subosito/gotenv v1.4.1 // indirect 220 | github.com/tdakkota/asciicheck v0.4.1 // indirect 221 | github.com/tetafro/godot v1.5.1 // indirect 222 | github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect 223 | github.com/timonwong/loggercheck v0.11.0 // indirect 224 | github.com/tomarrell/wrapcheck/v2 v2.11.0 // indirect 225 | github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect 226 | github.com/ultraware/funlen v0.2.0 // indirect 227 | github.com/ultraware/whitespace v0.2.0 // indirect 228 | github.com/uudashr/gocognit v1.2.0 // indirect 229 | github.com/uudashr/iface v1.3.1 // indirect 230 | github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect 231 | github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 232 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 233 | github.com/xen0n/gosmopolitan v1.3.0 // indirect 234 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 235 | github.com/yagipy/maintidx v1.0.0 // indirect 236 | github.com/yeya24/promlinter v0.3.0 // indirect 237 | github.com/ykadowak/zerologlint v0.1.5 // indirect 238 | github.com/yuin/goldmark v1.7.7 // indirect 239 | github.com/yuin/goldmark-meta v1.1.0 // indirect 240 | github.com/zclconf/go-cty v1.16.2 // indirect 241 | gitlab.com/bosi/decorder v0.4.2 // indirect 242 | go-simpler.org/musttag v0.13.1 // indirect 243 | go-simpler.org/sloglint v0.11.0 // indirect 244 | go.abhg.dev/goldmark/frontmatter v0.2.0 // indirect 245 | go.augendre.info/fatcontext v0.8.0 // indirect 246 | go.uber.org/atomic v1.7.0 // indirect 247 | go.uber.org/automaxprocs v1.6.0 // indirect 248 | go.uber.org/multierr v1.6.0 // indirect 249 | go.uber.org/zap v1.24.0 // indirect 250 | golang.org/x/crypto v0.38.0 // indirect 251 | golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect 252 | golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect 253 | golang.org/x/mod v0.24.0 // indirect 254 | golang.org/x/sync v0.14.0 // indirect 255 | golang.org/x/sys v0.33.0 // indirect 256 | golang.org/x/text v0.25.0 // indirect 257 | golang.org/x/tools v0.32.0 // indirect 258 | google.golang.org/appengine v1.6.8 // indirect 259 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 // indirect 260 | google.golang.org/grpc v1.72.1 // indirect 261 | google.golang.org/protobuf v1.36.6 // indirect 262 | gopkg.in/ini.v1 v1.67.0 // indirect 263 | gopkg.in/yaml.v2 v2.4.0 // indirect 264 | gopkg.in/yaml.v3 v3.0.1 // indirect 265 | honnef.co/go/tools v0.6.1 // indirect 266 | mvdan.cc/gofumpt v0.8.0 // indirect 267 | mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 // indirect 268 | ) 269 | -------------------------------------------------------------------------------- /internal/api/client.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "net/http" 10 | "net/url" 11 | "sync" 12 | 13 | "github.com/hashicorp/terraform-plugin-log/tflog" 14 | ) 15 | 16 | // UnauthorizedError represents the message of an HTTP 401 response. 17 | type UnauthorizedError ErrorMessage 18 | 19 | // UnprocessableEntityError represents the generic structure of an error response. 20 | type UnprocessableEntityError struct { 21 | Error ErrorMessage `json:"error"` 22 | } 23 | 24 | // ErrorMessage is the message of an error response. 25 | type ErrorMessage struct { 26 | Message string `json:"message"` 27 | } 28 | 29 | var ( 30 | ErrNotFound = errors.New("not found") 31 | ErrRateLimited = errors.New("rate limit exceeded") 32 | ) 33 | 34 | const ( 35 | RateLimitLimitHeader = "ratelimit-limit" 36 | RateLimitRemainingHeader = "ratelimit-remaining" 37 | RateLimitResetHeader = "ratelimit-reset" 38 | ) 39 | 40 | // Client for the Hetzner DNS API. 41 | type Client struct { 42 | requestLock sync.Mutex 43 | apiToken string 44 | userAgent string 45 | httpClient *http.Client 46 | endPoint *url.URL 47 | } 48 | 49 | // New creates a new API Client using a given api token. 50 | func New(apiEndpoint string, apiToken string, roundTripper http.RoundTripper) (*Client, error) { 51 | endPoint, err := url.Parse(apiEndpoint) 52 | if err != nil { 53 | return nil, fmt.Errorf("error parsing API endpoint URL: %w", err) 54 | } 55 | 56 | httpClient := &http.Client{ 57 | Transport: roundTripper, 58 | } 59 | 60 | client := &Client{ 61 | apiToken: apiToken, 62 | endPoint: endPoint, 63 | httpClient: httpClient, 64 | } 65 | 66 | return client, nil 67 | } 68 | 69 | func (c *Client) SetUserAgent(userAgent string) { 70 | c.userAgent = userAgent 71 | } 72 | 73 | func (c *Client) request(ctx context.Context, method string, path string, bodyJSON any) (*http.Response, error) { 74 | uri := c.endPoint.String() + path 75 | 76 | tflog.Debug(ctx, fmt.Sprintf("HTTP request to API %s %s", method, uri)) 77 | 78 | var ( 79 | err error 80 | reqBody []byte 81 | ) 82 | 83 | if bodyJSON != nil { 84 | reqBody, err = json.Marshal(bodyJSON) 85 | if err != nil { 86 | return nil, fmt.Errorf("error serializing JSON body %s", err) 87 | } 88 | } 89 | 90 | req, err := http.NewRequestWithContext(ctx, method, uri, bytes.NewReader(reqBody)) 91 | if err != nil { 92 | return nil, fmt.Errorf("error building request: %w", err) 93 | } 94 | 95 | // This lock ensures that only one request is sent to Hetzner API at a time. 96 | // See issue #5 for context. 97 | if method == http.MethodPost || method == http.MethodPut || method == http.MethodDelete { 98 | c.requestLock.Lock() 99 | defer c.requestLock.Unlock() 100 | } 101 | 102 | req.Header.Set("Auth-API-Token", c.apiToken) 103 | req.Header.Set("Accept", "application/json; charset=utf-8") 104 | req.Header.Set("Content-Type", "application/json; charset=utf-8") 105 | 106 | if c.userAgent != "" { 107 | req.Header.Set("User-Agent", c.userAgent) 108 | } 109 | 110 | resp, err := c.httpClient.Do(req) 111 | if err != nil { 112 | return nil, fmt.Errorf("error sending request: %w", err) 113 | } 114 | 115 | tflog.Debug(ctx, "Rate limit remaining: "+resp.Header.Get(RateLimitRemainingHeader)) 116 | 117 | switch resp.StatusCode { 118 | case http.StatusUnauthorized: 119 | unauthorizedError, err := parseUnauthorizedError(resp) 120 | if err != nil { 121 | return nil, err 122 | } 123 | 124 | return nil, fmt.Errorf("API returned HTTP 401 Unauthorized error with message: '%s'. "+ 125 | "Check if your API key is valid", unauthorizedError.Message) 126 | case http.StatusUnprocessableEntity: 127 | unprocessableEntityError, err := parseUnprocessableEntityError(resp) 128 | if err != nil { 129 | return nil, err 130 | } 131 | 132 | return nil, fmt.Errorf("API returned HTTP 422 Unprocessable Entity error with message: '%s'", unprocessableEntityError.Error.Message) 133 | case http.StatusTooManyRequests: 134 | tflog.Debug(ctx, "Rate limit limit: "+resp.Header.Get(RateLimitLimitHeader)) 135 | tflog.Debug(ctx, "Rate limit reset: "+resp.Header.Get(RateLimitResetHeader)) 136 | 137 | return nil, fmt.Errorf("API returned HTTP 429 Too Many Requests error: %w", ErrRateLimited) 138 | } 139 | 140 | return resp, nil 141 | } 142 | -------------------------------------------------------------------------------- /internal/api/client_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "net/http" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestClientCreateZoneSuccess(t *testing.T) { 15 | t.Parallel() 16 | 17 | var requestBodyReader io.Reader 18 | 19 | responseBody := []byte(`{"zone":{"id":"12345","name":"mydomain.com","ttl":3600}}`) 20 | config := RequestConfig{responseHTTPStatus: http.StatusOK, requestBodyReader: &requestBodyReader, responseBodyJSON: responseBody} 21 | client := createTestClient(t, config) 22 | 23 | opts := CreateZoneOpts{Name: "mydomain.com", TTL: 3600} 24 | zone, err := client.CreateZone(context.Background(), opts) 25 | 26 | require.NoError(t, err) 27 | assert.Equal(t, Zone{ID: "12345", Name: "mydomain.com", TTL: 3600}, *zone) 28 | assert.NotNil(t, requestBodyReader, "The request body should not be nil") 29 | jsonRequestBody, _ := io.ReadAll(requestBodyReader) 30 | assert.JSONEq(t, `{"name":"mydomain.com","ttl":3600}`, string(jsonRequestBody)) 31 | } 32 | 33 | func TestClientCreateZoneInvalidDomain(t *testing.T) { 34 | t.Parallel() 35 | 36 | //nolint:lll 37 | responseBody := []byte(`{"zone": {"id":"","name":"","ttl":0,"registrar":"","legacy_dns_host":"","legacy_ns":null,"ns":null,"created":"","verified":"","modified":"","project":"","owner":"","permission":"","zone_type":{"id":"","name":"","description":"","prices":null},"status":"","paused":false,"is_secondary_dns":false,"txt_verification":{"name":"","token":""},"records_count":0},"error":{"message":"422 : invalid TLD","code":422}}`) 38 | config := RequestConfig{responseHTTPStatus: http.StatusUnprocessableEntity, responseBodyJSON: responseBody} 39 | 40 | client := createTestClient(t, config) 41 | opts := CreateZoneOpts{Name: "this.is.invalid", TTL: 3600} 42 | _, err := client.CreateZone(context.Background(), opts) 43 | 44 | require.ErrorContains(t, err, "API returned HTTP 422 Unprocessable Entity error with message: '422 : invalid TLD'") 45 | } 46 | 47 | func TestClientCreateZoneInvalidTLD(t *testing.T) { 48 | t.Parallel() 49 | 50 | var irrelevantConfig RequestConfig 51 | client := createTestClient(t, irrelevantConfig) 52 | opts := CreateZoneOpts{Name: "thisisinvalid", TTL: 3600} 53 | _, err := client.CreateZone(context.Background(), opts) 54 | 55 | require.ErrorContains(t, err, "'thisisinvalid' is not a valid domain") 56 | } 57 | 58 | func TestClientUpdateZoneSuccess(t *testing.T) { 59 | t.Parallel() 60 | 61 | zoneWithUpdates := Zone{ID: "12345678", Name: "zone1.online", TTL: 3600, NS: []string{"ns1.zone1.online", "ns2.zone1.online"}} 62 | zoneWithUpdatesJSON := `{"id":"12345678","name":"zone1.online","ns":["ns1.zone1.online","ns2.zone1.online"],"ttl":3600}` 63 | 64 | var requestBodyReader io.Reader 65 | 66 | responseBody := []byte(`{"zone":{"id":"12345678","name":"zone1.online","ns":["ns1.zone1.online","ns2.zone1.online"],"ttl":3600}}`) 67 | config := RequestConfig{responseHTTPStatus: http.StatusOK, requestBodyReader: &requestBodyReader, responseBodyJSON: responseBody} 68 | client := createTestClient(t, config) 69 | 70 | updatedZone, err := client.UpdateZone(context.Background(), zoneWithUpdates) 71 | 72 | require.NoError(t, err) 73 | assert.Equal(t, zoneWithUpdates, *updatedZone) 74 | assert.NotNil(t, requestBodyReader, "The request body should not be nil") 75 | jsonRequestBody, _ := io.ReadAll(requestBodyReader) 76 | assert.JSONEq(t, zoneWithUpdatesJSON, string(jsonRequestBody)) 77 | } 78 | 79 | func TestClientGetZone(t *testing.T) { 80 | t.Parallel() 81 | 82 | responseBody := []byte(`{"zone":{"id":"12345678","name":"zone1.online","ttl":3600}}`) 83 | config := RequestConfig{responseHTTPStatus: http.StatusOK, responseBodyJSON: responseBody} 84 | client := createTestClient(t, config) 85 | 86 | zone, err := client.GetZone(context.Background(), "12345678") 87 | 88 | require.NoError(t, err) 89 | assert.Equal(t, Zone{ID: "12345678", Name: "zone1.online", TTL: 3600}, *zone) 90 | } 91 | 92 | func TestClientGetZoneReturnNilIfNotFound(t *testing.T) { 93 | t.Parallel() 94 | 95 | config := RequestConfig{responseHTTPStatus: http.StatusNotFound} 96 | client := createTestClient(t, config) 97 | 98 | zone, err := client.GetZone(context.Background(), "12345678") 99 | 100 | require.ErrorIs(t, err, ErrNotFound) 101 | assert.Nil(t, zone) 102 | } 103 | 104 | func TestClientGetZoneByName(t *testing.T) { 105 | t.Parallel() 106 | 107 | responseBody := []byte(`{"zones":[{"id":"12345678","name":"zone1.online","ttl":3600}]}`) 108 | config := RequestConfig{responseHTTPStatus: http.StatusOK, responseBodyJSON: responseBody} 109 | client := createTestClient(t, config) 110 | 111 | zone, err := client.GetZoneByName(context.Background(), "zone1.online") 112 | 113 | require.NoError(t, err) 114 | assert.Equal(t, Zone{ID: "12345678", Name: "zone1.online", TTL: 3600}, *zone) 115 | } 116 | 117 | func TestClientGetZoneByNameReturnNilIfnotFound(t *testing.T) { 118 | t.Parallel() 119 | 120 | config := RequestConfig{responseHTTPStatus: http.StatusNotFound} 121 | client := createTestClient(t, config) 122 | 123 | zone, err := client.GetZoneByName(context.Background(), "zone1.online") 124 | 125 | require.ErrorIs(t, err, ErrNotFound) 126 | assert.Nil(t, zone) 127 | } 128 | 129 | func TestClientDeleteZone(t *testing.T) { 130 | t.Parallel() 131 | 132 | config := RequestConfig{responseHTTPStatus: http.StatusOK} 133 | client := createTestClient(t, config) 134 | 135 | err := client.DeleteZone(context.Background(), "irrelevant") 136 | 137 | require.NoError(t, err) 138 | } 139 | 140 | func TestClientGetRecord(t *testing.T) { 141 | t.Parallel() 142 | 143 | aTTL := int64(3600) 144 | responseBody := []byte(`{"record":{"zone_id":"wwwlsksjjenm","id":"12345678","name":"zone1.online","ttl":3600,"type":"A","value":"192.168.1.1"}}`) 145 | config := RequestConfig{responseHTTPStatus: http.StatusOK, responseBodyJSON: responseBody} 146 | client := createTestClient(t, config) 147 | 148 | record, err := client.GetRecord(context.Background(), "12345678") 149 | 150 | require.NoError(t, err) 151 | assert.Equal(t, Record{ZoneID: "wwwlsksjjenm", ID: "12345678", Name: "zone1.online", TTL: &aTTL, Type: "A", Value: "192.168.1.1"}, *record) 152 | } 153 | 154 | func TestClientGetRecordWithUndefinedTTL(t *testing.T) { 155 | t.Parallel() 156 | 157 | responseBody := []byte(`{"record":{"zone_id":"wwwlsksjjenm","id":"12345678","name":"zone1.online","type":"A","value":"192.168.1.1"}}`) 158 | config := RequestConfig{responseHTTPStatus: http.StatusOK, responseBodyJSON: responseBody} 159 | client := createTestClient(t, config) 160 | 161 | record, err := client.GetRecord(context.Background(), "12345678") 162 | 163 | require.NoError(t, err) 164 | assert.Equal(t, Record{ZoneID: "wwwlsksjjenm", ID: "12345678", Name: "zone1.online", TTL: nil, Type: "A", Value: "192.168.1.1"}, *record) 165 | } 166 | 167 | func TestClientGetRecordReturnNilIfNotFound(t *testing.T) { 168 | t.Parallel() 169 | 170 | config := RequestConfig{responseHTTPStatus: http.StatusNotFound} 171 | client := createTestClient(t, config) 172 | 173 | record, err := client.GetRecord(context.Background(), "irrelevant") 174 | 175 | require.Error(t, err) 176 | assert.Nil(t, record) 177 | } 178 | 179 | func TestClientCreateRecordSuccess(t *testing.T) { 180 | t.Parallel() 181 | 182 | var requestBodyReader io.Reader 183 | 184 | responseBody := []byte(`{"record":{"zone_id":"wwwlsksjjenm","id":"12345678","name":"zone1.online","ttl":3600,"type":"A","value":"192.168.1.1"}}`) 185 | config := RequestConfig{responseHTTPStatus: http.StatusOK, requestBodyReader: &requestBodyReader, responseBodyJSON: responseBody} 186 | client := createTestClient(t, config) 187 | 188 | aTTL := int64(3600) 189 | opts := CreateRecordOpts{ZoneID: "wwwlsksjjenm", Name: "zone1.online", TTL: &aTTL, Type: "A", Value: "192.168.1.1"} 190 | record, err := client.CreateRecord(context.Background(), opts) 191 | 192 | require.NoError(t, err) 193 | assert.Equal(t, Record{ZoneID: "wwwlsksjjenm", ID: "12345678", Name: "zone1.online", TTL: &aTTL, Type: "A", Value: "192.168.1.1"}, *record) 194 | assert.NotNil(t, requestBodyReader, "The request body should not be nil") 195 | jsonRequestBody, _ := io.ReadAll(requestBodyReader) 196 | assert.JSONEq(t, `{"zone_id":"wwwlsksjjenm","type":"A","name":"zone1.online","value":"192.168.1.1","ttl":3600}`, string(jsonRequestBody)) 197 | } 198 | 199 | func TestClientRecordZone(t *testing.T) { 200 | t.Parallel() 201 | 202 | config := RequestConfig{responseHTTPStatus: http.StatusOK} 203 | client := createTestClient(t, config) 204 | 205 | err := client.DeleteRecord(context.Background(), "irrelevant") 206 | 207 | require.NoError(t, err) 208 | } 209 | 210 | func TestClientUpdateRecordSuccess(t *testing.T) { 211 | t.Parallel() 212 | 213 | aTTL := int64(3600) 214 | recordWithUpdates := Record{ZoneID: "wwwlsksjjenm", ID: "12345678", Name: "zone2.online", TTL: &aTTL, Type: "A", Value: "192.168.1.1"} 215 | recordWithUpdatesJSON := `{"zone_id":"wwwlsksjjenm","id":"12345678","type":"A","name":"zone2.online","value":"192.168.1.1","ttl":3600}` 216 | 217 | var requestBodyReader io.Reader 218 | 219 | responseBody := []byte(`{"record":{"zone_id":"wwwlsksjjenm","id":"12345678","type":"A","name":"zone2.online","value":"192.168.1.1","ttl":3600}}`) 220 | config := RequestConfig{responseHTTPStatus: http.StatusOK, requestBodyReader: &requestBodyReader, responseBodyJSON: responseBody} 221 | client := createTestClient(t, config) 222 | 223 | updatedRecord, err := client.UpdateRecord(context.Background(), recordWithUpdates) 224 | 225 | require.NoError(t, err) 226 | assert.Equal(t, recordWithUpdates, *updatedRecord) 227 | assert.NotNil(t, requestBodyReader, "The request body should not be nil") 228 | jsonRequestBody, _ := io.ReadAll(requestBodyReader) 229 | assert.JSONEq(t, recordWithUpdatesJSON, string(jsonRequestBody)) 230 | } 231 | 232 | func TestClientHandleUnauthorizedRequest(t *testing.T) { 233 | t.Parallel() 234 | 235 | responseBody := []byte(`{"message":"Invalid API key"}`) 236 | config := RequestConfig{responseHTTPStatus: http.StatusUnauthorized, responseBodyJSON: responseBody} 237 | client := createTestClient(t, config) 238 | 239 | opts := CreateZoneOpts{Name: "mydomain.com", TTL: 3600} 240 | _, err := client.CreateZone(context.Background(), opts) 241 | 242 | require.ErrorContains(t, err, "'Invalid API key'", "Error message didn't contain error message from API.") 243 | } 244 | 245 | type RequestConfig struct { 246 | responseHTTPStatus int 247 | responseBodyJSON []byte 248 | requestBodyReader *io.Reader 249 | } 250 | 251 | func createTestClient(t testing.TB, config RequestConfig) *Client { 252 | t.Helper() 253 | 254 | client, err := New("http://localhost/", "irrelevant", TestClient{config: config}) 255 | require.NoError(t, err) 256 | 257 | return client 258 | } 259 | 260 | type TestClient struct { 261 | config RequestConfig 262 | } 263 | 264 | // See https://golang.org/pkg/net/http/#RoundTripper 265 | func (f TestClient) RoundTrip(req *http.Request) (*http.Response, error) { 266 | if req.Body != nil && f.config.requestBodyReader != nil { 267 | *f.config.requestBodyReader = req.Body 268 | } 269 | 270 | var jsonBody io.ReadCloser = nil 271 | if f.config.responseBodyJSON != nil { 272 | jsonBody = io.NopCloser(bytes.NewReader(f.config.responseBodyJSON)) 273 | } 274 | 275 | resp := http.Response{StatusCode: f.config.responseHTTPStatus, Body: jsonBody} 276 | 277 | return &resp, nil 278 | } 279 | -------------------------------------------------------------------------------- /internal/api/nameserver.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | ) 8 | 9 | type Nameserver map[string]string 10 | 11 | func resolveIP(ctx context.Context, name string) (map[string]string, error) { 12 | var ( 13 | resolver = net.Resolver{PreferGo: true} 14 | ipv4, ipv6 string 15 | ) 16 | 17 | addrs, err := resolver.LookupIPAddr(ctx, name) 18 | if err != nil { 19 | return nil, fmt.Errorf("error resolving IP address for %s: %w", name, err) 20 | } 21 | 22 | for _, addr := range addrs { 23 | if addr.IP.To4() != nil { 24 | ipv4 = addr.IP.String() 25 | } else { 26 | ipv6 = addr.IP.String() 27 | } 28 | } 29 | 30 | return map[string]string{ 31 | "ipv4": ipv4, 32 | "ipv6": ipv6, 33 | }, nil 34 | } 35 | 36 | func generateNameserverData(ctx context.Context, nameservers []string) ([]Nameserver, error) { 37 | nsData := make([]Nameserver, 0, len(nameservers)) 38 | 39 | for _, ns := range nameservers { 40 | resolvedNS, err := resolveIP(ctx, ns) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | nsData = append(nsData, Nameserver{ 46 | "name": ns, 47 | "ipv4": resolvedNS["ipv4"], 48 | "ipv6": resolvedNS["ipv6"], 49 | }) 50 | } 51 | 52 | return nsData, nil 53 | } 54 | 55 | // GetAuthoritativeNameservers returns a list of all Hetzner DNS authoritative name servers. 56 | // Currently, the list is hard-coded because Hetzner DNS does not provide an API to retrieve this information. 57 | func GetAuthoritativeNameservers(ctx context.Context) ([]Nameserver, error) { 58 | return generateNameserverData(ctx, []string{ 59 | "helium.ns.hetzner.de.", 60 | "hydrogen.ns.hetzner.com.", 61 | "oxygen.ns.hetzner.com.", 62 | }) 63 | } 64 | 65 | // GetSecondaryNameservers is a list of all Hetzner DNS secondary name servers. 66 | // Currently, the list is hard-coded because Hetzner DNS does not provide an API to retrieve this information. 67 | func GetSecondaryNameservers(ctx context.Context) ([]Nameserver, error) { 68 | return generateNameserverData(ctx, []string{ 69 | "ns1.first-ns.de.", 70 | "robotns2.second-ns.de.", 71 | "robotns3.second-ns.com.", 72 | }) 73 | } 74 | 75 | // GetKonsolehNameservers is a list of all Hetzner DNS KonsoleH name servers. 76 | // Currently, the list is hard-coded because Hetzner DNS does not provide an API to retrieve this information. 77 | func GetKonsolehNameservers(ctx context.Context) ([]Nameserver, error) { 78 | return generateNameserverData(ctx, []string{ 79 | "ns1.your-server.de.", 80 | "ns.second-ns.com.", 81 | "ns3.second-ns.de.", 82 | }) 83 | } 84 | -------------------------------------------------------------------------------- /internal/api/primary_server.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | type PrimaryServer struct { 10 | ID string `json:"id"` 11 | Port int64 `json:"port"` 12 | ZoneID string `json:"zone_id"` 13 | Address string `json:"address"` 14 | } 15 | 16 | type CreatePrimaryServerRequest struct { 17 | Port int64 `json:"port"` 18 | ZoneID string `json:"zone_id"` 19 | Address string `json:"address"` 20 | } 21 | 22 | type PrimaryServersResponse struct { 23 | PrimaryServers []PrimaryServer `json:"primary_servers"` 24 | } 25 | 26 | type PrimaryServerResponse struct { 27 | PrimaryServer PrimaryServer `json:"primary_server"` 28 | } 29 | 30 | func (c *Client) GetPrimaryServer(ctx context.Context, id string) (*PrimaryServer, error) { 31 | resp, err := c.request(ctx, http.MethodGet, "/api/v1/primary_servers/"+id, nil) 32 | if err != nil { 33 | return nil, fmt.Errorf("error getting primary server %s: %w", id, err) 34 | } 35 | 36 | switch resp.StatusCode { 37 | case http.StatusNotFound: 38 | return nil, fmt.Errorf("primary server %s: %w", id, ErrNotFound) 39 | case http.StatusOK: 40 | var response *PrimaryServerResponse 41 | 42 | err = readAndParseJSONBody(resp, &response) 43 | if err != nil { 44 | return nil, fmt.Errorf("error Reading json response of get primary server %s request: %s", id, err) 45 | } 46 | 47 | return &response.PrimaryServer, nil 48 | default: 49 | return nil, fmt.Errorf("http status %d unhandled", resp.StatusCode) 50 | } 51 | } 52 | 53 | func (c *Client) GetPrimaryServers(ctx context.Context, zoneID string) ([]PrimaryServer, error) { 54 | resp, err := c.request(ctx, http.MethodGet, "/api/v1/primary_servers?zone_id="+zoneID, nil) 55 | if err != nil { 56 | return nil, fmt.Errorf("error getting primary servers for zone %s: %w", zoneID, err) 57 | } 58 | 59 | switch resp.StatusCode { 60 | case http.StatusOK: 61 | var response PrimaryServersResponse 62 | 63 | err = readAndParseJSONBody(resp, &response) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | return response.PrimaryServers, nil 69 | default: 70 | return nil, fmt.Errorf("http status %d unhandled", resp.StatusCode) 71 | } 72 | } 73 | 74 | func (c *Client) CreatePrimaryServer(ctx context.Context, server CreatePrimaryServerRequest) (*PrimaryServer, error) { 75 | resp, err := c.request(ctx, http.MethodPost, "/api/v1/primary_servers", server) 76 | if err != nil { 77 | return nil, fmt.Errorf("error creating primary server %s: %w", server.Address, err) 78 | } 79 | 80 | switch resp.StatusCode { 81 | case http.StatusOK: 82 | var response PrimaryServerResponse 83 | 84 | err = readAndParseJSONBody(resp, &response) 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | return &response.PrimaryServer, nil 90 | default: 91 | return nil, fmt.Errorf("http status %d unhandled", resp.StatusCode) 92 | } 93 | } 94 | 95 | func (c *Client) UpdatePrimaryServer(ctx context.Context, server PrimaryServer) (*PrimaryServer, error) { 96 | resp, err := c.request(ctx, http.MethodPut, "/api/v1/primary_servers/"+server.ID, server) 97 | if err != nil { 98 | return nil, fmt.Errorf("error updating primary server %s: %w", server.ID, err) 99 | } 100 | 101 | switch resp.StatusCode { 102 | case http.StatusOK: 103 | var response PrimaryServerResponse 104 | 105 | err = readAndParseJSONBody(resp, &response) 106 | if err != nil { 107 | return nil, err 108 | } 109 | 110 | return &response.PrimaryServer, nil 111 | default: 112 | return nil, fmt.Errorf("http status %d unhandled", resp.StatusCode) 113 | } 114 | } 115 | 116 | func (c *Client) DeletePrimaryServer(ctx context.Context, id string) error { 117 | resp, err := c.request(ctx, http.MethodDelete, "/api/v1/primary_servers/"+id, nil) 118 | if err != nil { 119 | return fmt.Errorf("error deleting primary server %s: %w", id, err) 120 | } 121 | 122 | switch resp.StatusCode { 123 | case http.StatusOK: 124 | return nil 125 | default: 126 | return fmt.Errorf("http status %d unhandled", resp.StatusCode) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /internal/api/record.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | // Record represents a record in a specific Zone. 10 | type Record struct { 11 | ZoneID string `json:"zone_id"` 12 | ID string `json:"id"` 13 | Type string `json:"type"` 14 | Name string `json:"name"` 15 | Value string `json:"value"` 16 | TTL *int64 `json:"ttl,omitempty"` 17 | } 18 | 19 | // HasTTL returns true if a Record has a TTL set and false if TTL is undefined. 20 | func (r *Record) HasTTL() bool { 21 | return r.TTL != nil 22 | } 23 | 24 | // CreateRecordOpts covers all parameters used to create a new DNS record. 25 | type CreateRecordOpts struct { 26 | ZoneID string `json:"zone_id"` 27 | Type string `json:"type"` 28 | Name string `json:"name"` 29 | Value string `json:"value"` 30 | TTL *int64 `json:"ttl,omitempty"` 31 | } 32 | 33 | // CreateRecordRequest represents all data required to create a new record. 34 | type CreateRecordRequest struct { 35 | ZoneID string `json:"zone_id"` 36 | Type string `json:"type"` 37 | Name string `json:"name"` 38 | Value string `json:"value"` 39 | TTL *int64 `json:"ttl,omitempty"` 40 | } 41 | 42 | // RecordsResponse represents a response from the API containing a list of records. 43 | type RecordsResponse struct { 44 | Records []Record `json:"records"` 45 | } 46 | 47 | // RecordResponse represents a response from the API containing only one record. 48 | type RecordResponse struct { 49 | Record Record `json:"record"` 50 | } 51 | 52 | // GetRecordByName reads the current state of a DNS Record with a given name and zone id. 53 | func (c *Client) GetRecordByName(ctx context.Context, zoneID string, name string) (*Record, error) { 54 | resp, err := c.request(ctx, http.MethodGet, "/api/v1/records?zone_id="+zoneID, nil) 55 | if err != nil { 56 | return nil, fmt.Errorf("error getting records in zone %s: %w", zoneID, err) 57 | } 58 | 59 | switch resp.StatusCode { 60 | case http.StatusOK: 61 | var response *RecordsResponse 62 | 63 | err = readAndParseJSONBody(resp, &response) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | if len(response.Records) == 0 { 69 | return nil, fmt.Errorf("it seems there are no records in zone %s at all", zoneID) 70 | } 71 | 72 | for _, record := range response.Records { 73 | if record.Name == name { 74 | return &record, nil 75 | } 76 | } 77 | 78 | return nil, fmt.Errorf("there are records in zone %s, but %s isn't included", zoneID, name) 79 | default: 80 | return nil, fmt.Errorf("http status %d unhandled", resp.StatusCode) 81 | } 82 | } 83 | 84 | // GetRecords reads all records in a given zone. 85 | func (c *Client) GetRecordsByZoneID(ctx context.Context, zoneID string) (*[]Record, error) { 86 | resp, err := c.request(ctx, http.MethodGet, "/api/v1/records?zone_id="+zoneID, nil) 87 | if err != nil { 88 | return nil, fmt.Errorf("error getting records in zone %s: %w", zoneID, err) 89 | } 90 | 91 | switch resp.StatusCode { 92 | case http.StatusOK: 93 | var response *RecordsResponse 94 | 95 | err = readAndParseJSONBody(resp, &response) 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | return &response.Records, nil 101 | default: 102 | return nil, fmt.Errorf("http status %d unhandled", resp.StatusCode) 103 | } 104 | } 105 | 106 | // GetRecord reads the current state of a DNS Record. 107 | func (c *Client) GetRecord(ctx context.Context, recordID string) (*Record, error) { 108 | resp, err := c.request(ctx, http.MethodGet, "/api/v1/records/"+recordID, nil) 109 | if err != nil { 110 | return nil, fmt.Errorf("error getting record %s: %w", recordID, err) 111 | } 112 | 113 | switch resp.StatusCode { 114 | case http.StatusNotFound: 115 | return nil, fmt.Errorf("record %s: %w", recordID, ErrNotFound) 116 | case http.StatusOK: 117 | var response *RecordResponse 118 | 119 | err = readAndParseJSONBody(resp, &response) 120 | if err != nil { 121 | return nil, fmt.Errorf("error Reading json response of get record %s request: %s", recordID, err) 122 | } 123 | 124 | return &response.Record, nil 125 | default: 126 | return nil, fmt.Errorf("http status %d unhandled", resp.StatusCode) 127 | } 128 | } 129 | 130 | // CreateRecord create a new DNS records. 131 | func (c *Client) CreateRecord(ctx context.Context, opts CreateRecordOpts) (*Record, error) { 132 | reqBody := CreateRecordRequest(opts) 133 | 134 | resp, err := c.request(ctx, http.MethodPost, "/api/v1/records", reqBody) 135 | if err != nil { 136 | return nil, fmt.Errorf("error creating record in zone %s: %w", opts.ZoneID, err) 137 | } 138 | 139 | switch resp.StatusCode { 140 | case http.StatusNotFound: 141 | return nil, fmt.Errorf("zone %s: %w", opts.ZoneID, ErrNotFound) 142 | case http.StatusOK: 143 | var response RecordResponse 144 | 145 | err = readAndParseJSONBody(resp, &response) 146 | if err != nil { 147 | return nil, err 148 | } 149 | 150 | return &response.Record, nil 151 | default: 152 | return nil, fmt.Errorf("http status %d unhandled", resp.StatusCode) 153 | } 154 | } 155 | 156 | // DeleteRecord deletes a given record. 157 | func (c *Client) DeleteRecord(ctx context.Context, id string) error { 158 | resp, err := c.request(ctx, http.MethodDelete, "/api/v1/records/"+id, nil) 159 | if err != nil { 160 | return fmt.Errorf("error deleting record %s: %s", id, err) 161 | } 162 | 163 | switch resp.StatusCode { 164 | case http.StatusOK: 165 | return nil 166 | default: 167 | return fmt.Errorf("http status %d unhandled", resp.StatusCode) 168 | } 169 | } 170 | 171 | // UpdateRecord create a new DNS records. 172 | func (c *Client) UpdateRecord(ctx context.Context, record Record) (*Record, error) { 173 | resp, err := c.request(ctx, http.MethodPut, "/api/v1/records/"+record.ID, record) 174 | if err != nil { 175 | return nil, fmt.Errorf("error updating record %s: %s", record.ID, err) 176 | } 177 | 178 | switch resp.StatusCode { 179 | case http.StatusOK: 180 | var response RecordResponse 181 | 182 | err = readAndParseJSONBody(resp, &response) 183 | if err != nil { 184 | return nil, err 185 | } 186 | 187 | return &response.Record, nil 188 | default: 189 | return nil, fmt.Errorf("http status %d unhandled", resp.StatusCode) 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /internal/api/utils.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | ) 9 | 10 | func parseUnprocessableEntityError(resp *http.Response) (*UnprocessableEntityError, error) { 11 | body, err := io.ReadAll(resp.Body) 12 | defer resp.Body.Close() 13 | 14 | if err != nil { 15 | return nil, fmt.Errorf("error reading HTTP response body: %e", err) 16 | } 17 | 18 | var unprocessableEntityError UnprocessableEntityError 19 | 20 | err = json.Unmarshal(body, &unprocessableEntityError) 21 | if err != nil { 22 | return nil, fmt.Errorf("error parsing JSON response body: %e", err) 23 | } 24 | 25 | return &unprocessableEntityError, nil 26 | } 27 | 28 | func parseUnauthorizedError(resp *http.Response) (*UnauthorizedError, error) { 29 | var unauthorizedError UnauthorizedError 30 | 31 | err := readAndParseJSONBody(resp, &unauthorizedError) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | return &unauthorizedError, nil 37 | } 38 | 39 | func readBody(resp *http.Response) ([]byte, error) { 40 | body, err := io.ReadAll(resp.Body) 41 | defer resp.Body.Close() 42 | 43 | if err != nil { 44 | return nil, fmt.Errorf("error reading HTTP response body: %w", err) 45 | } 46 | 47 | return body, nil 48 | } 49 | 50 | func readAndParseJSONBody(resp *http.Response, respType interface{}) error { 51 | body, err := readBody(resp) 52 | if err != nil { 53 | return fmt.Errorf("error reading HTTP response body %w", err) 54 | } 55 | 56 | if err = json.Unmarshal(body, &respType); err != nil { 57 | return fmt.Errorf("error parsing JSON response body %w", err) 58 | } 59 | 60 | return nil 61 | } 62 | -------------------------------------------------------------------------------- /internal/api/zone.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | ) 9 | 10 | // Zone represents a DNS Zone. 11 | type Zone struct { 12 | ID string `json:"id"` 13 | Name string `json:"name"` 14 | NS []string `json:"ns"` 15 | TTL int64 `json:"ttl"` 16 | } 17 | 18 | // CreateZoneOpts covers all parameters used to create a new DNS zone. 19 | type CreateZoneOpts struct { 20 | Name string `json:"name"` 21 | TTL int64 `json:"ttl"` 22 | } 23 | 24 | // CreateZoneRequest represents the body of a POST Zone request. 25 | type CreateZoneRequest struct { 26 | Name string `json:"name"` 27 | TTL int64 `json:"ttl"` 28 | } 29 | 30 | // CreateZoneResponse represents the content of a POST Zone response. 31 | type CreateZoneResponse struct { 32 | Zone Zone `json:"zone"` 33 | } 34 | 35 | // GetZoneResponse represents the content of a GET Zone request. 36 | type GetZoneResponse struct { 37 | Zone Zone `json:"zone"` 38 | } 39 | 40 | // ZoneResponse represents the content of response containing a Zone. 41 | type ZoneResponse struct { 42 | Zone Zone `json:"zone"` 43 | } 44 | 45 | // GetZones represents the content of a GET Zones response. 46 | type GetZones struct { 47 | Zones []Zone `json:"zones"` 48 | } 49 | 50 | // GetZonesByNameResponse represents the content of a GET Zones response. 51 | type GetZonesByNameResponse struct { 52 | Zones []Zone `json:"zones"` 53 | } 54 | 55 | // GetZones reads the current state of a DNS zone. 56 | func (c *Client) GetZones(ctx context.Context) ([]Zone, error) { 57 | resp, err := c.request(ctx, http.MethodGet, "/api/v1/zones", nil) 58 | if err != nil { 59 | return nil, fmt.Errorf("error getting zones: %w", err) 60 | } 61 | 62 | switch resp.StatusCode { 63 | case http.StatusNotFound: 64 | // Undocumented API behavior: Hetzner DNS API returns 404 when there are no zones 65 | return nil, fmt.Errorf("zones: %w", ErrNotFound) 66 | case http.StatusOK: 67 | var response GetZones 68 | 69 | err = readAndParseJSONBody(resp, &response) 70 | if err != nil { 71 | return nil, err 72 | } 73 | 74 | return response.Zones, nil 75 | default: 76 | return nil, fmt.Errorf("error getting zones. HTTP status %d unhandled", resp.StatusCode) 77 | } 78 | } 79 | 80 | // GetZone reads the current state of a DNS zone. 81 | func (c *Client) GetZone(ctx context.Context, id string) (*Zone, error) { 82 | resp, err := c.request(ctx, http.MethodGet, "/api/v1/zones/"+id, nil) 83 | if err != nil { 84 | return nil, fmt.Errorf("error getting zone %s: %w", id, err) 85 | } 86 | 87 | switch resp.StatusCode { 88 | case http.StatusNotFound: 89 | return nil, fmt.Errorf("zone %s: %w", id, ErrNotFound) 90 | case http.StatusOK: 91 | var response GetZoneResponse 92 | 93 | err = readAndParseJSONBody(resp, &response) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | return &response.Zone, nil 99 | default: 100 | return nil, fmt.Errorf("http status %d unhandled", resp.StatusCode) 101 | } 102 | } 103 | 104 | // UpdateZone takes the passed state and updates the respective Zone. 105 | func (c *Client) UpdateZone(ctx context.Context, zone Zone) (*Zone, error) { 106 | resp, err := c.request(ctx, http.MethodPut, "/api/v1/zones/"+zone.ID, zone) 107 | if err != nil { 108 | return nil, fmt.Errorf("error updating zone %s: %s", zone.ID, err) 109 | } 110 | 111 | switch resp.StatusCode { 112 | case http.StatusOK: 113 | var response ZoneResponse 114 | 115 | err = readAndParseJSONBody(resp, &response) 116 | if err != nil { 117 | return nil, err 118 | } 119 | 120 | return &response.Zone, nil 121 | default: 122 | return nil, fmt.Errorf("http status %d unhandled", resp.StatusCode) 123 | } 124 | } 125 | 126 | // DeleteZone deletes a given DNS zone. 127 | func (c *Client) DeleteZone(ctx context.Context, id string) error { 128 | resp, err := c.request(ctx, http.MethodDelete, "/api/v1/zones/"+id, nil) 129 | if err != nil { 130 | return fmt.Errorf("error deleting zone %s: %s", id, err) 131 | } 132 | 133 | switch resp.StatusCode { 134 | case http.StatusOK: 135 | return nil 136 | default: 137 | return fmt.Errorf("http status %d unhandled", resp.StatusCode) 138 | } 139 | } 140 | 141 | // GetZoneByName reads the current state of a DNS zone with a given name. 142 | func (c *Client) GetZoneByName(ctx context.Context, name string) (*Zone, error) { 143 | resp, err := c.request(ctx, http.MethodGet, "/api/v1/zones?name="+name, nil) 144 | if err != nil { 145 | return nil, fmt.Errorf("error getting zones: %w", err) 146 | } 147 | 148 | switch resp.StatusCode { 149 | case http.StatusNotFound: 150 | return nil, fmt.Errorf("zone %s: %w", name, ErrNotFound) 151 | case http.StatusOK: 152 | var response GetZones 153 | 154 | err = readAndParseJSONBody(resp, &response) 155 | if err != nil { 156 | return nil, err 157 | } 158 | 159 | if len(response.Zones) != 1 { 160 | return nil, fmt.Errorf("error getting zone '%s'. No matching zone or multiple matching zones found", name) 161 | } 162 | 163 | return &response.Zones[0], nil 164 | default: 165 | return nil, fmt.Errorf("error getting zones. HTTP status %d unhandled", resp.StatusCode) 166 | } 167 | } 168 | 169 | // CreateZone creates a new DNS zone. 170 | func (c *Client) CreateZone(ctx context.Context, opts CreateZoneOpts) (*Zone, error) { 171 | if !strings.Contains(opts.Name, ".") { 172 | return nil, fmt.Errorf("error creating zone. The name '%s' is not a valid domain. It must correspond to the schema .", opts.Name) 173 | } 174 | 175 | reqBody := CreateZoneRequest(opts) 176 | 177 | resp, err := c.request(ctx, http.MethodPost, "/api/v1/zones", reqBody) 178 | if err != nil { 179 | return nil, fmt.Errorf("error getting zones: %w", err) 180 | } 181 | 182 | switch resp.StatusCode { 183 | case http.StatusOK: 184 | var response CreateZoneResponse 185 | 186 | err = readAndParseJSONBody(resp, &response) 187 | if err != nil { 188 | return nil, err 189 | } 190 | 191 | return &response.Zone, nil 192 | default: 193 | return nil, fmt.Errorf("http status %d unhandled", resp.StatusCode) 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /internal/api/zone_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func assertSerializeAndAssertEqual(t *testing.T, o interface{}, expectedJSON string) { 11 | computedJSON, err := json.Marshal(o) 12 | if err != nil { 13 | t.Fatal(err) 14 | } 15 | 16 | assert.JSONEq(t, expectedJSON, string(computedJSON)) 17 | } 18 | 19 | func TestCreateZoneRequestJson(t *testing.T) { 20 | req := CreateZoneRequest{Name: "aName", TTL: 60} 21 | expectedJSON := `{"name":"aName","ttl":60}` 22 | 23 | assertSerializeAndAssertEqual(t, req, expectedJSON) 24 | } 25 | 26 | func TestGetZoneResponseJson(t *testing.T) { 27 | resp := GetZoneResponse{Zone: Zone{ID: "aId", Name: "aName", TTL: 60, NS: []string{"ns1", "ns2"}}} 28 | expectedJSON := `{"zone":{"id":"aId","name":"aName","ns":["ns1","ns2"],"ttl":60}}` 29 | 30 | assertSerializeAndAssertEqual(t, resp, expectedJSON) 31 | } 32 | 33 | func TestGetZoneByNameResponseJson(t *testing.T) { 34 | resp := GetZonesByNameResponse{[]Zone{{ID: "aId", Name: "aName", TTL: 60, NS: []string{"ns1", "ns2"}}}} 35 | expectedJSON := `{"zones":[{"id":"aId","name":"aName","ns":["ns1","ns2"],"ttl":60}]}` 36 | 37 | assertSerializeAndAssertEqual(t, resp, expectedJSON) 38 | } 39 | 40 | func TestCreateZoneResponseJson(t *testing.T) { 41 | resp := CreateZoneResponse{Zone: Zone{ID: "aId", Name: "aName", TTL: 60, NS: []string{"ns1", "ns2"}}} 42 | expectedJSON := `{"zone":{"id":"aId","name":"aName","ns":["ns1","ns2"],"ttl":60}}` 43 | 44 | assertSerializeAndAssertEqual(t, resp, expectedJSON) 45 | } 46 | -------------------------------------------------------------------------------- /internal/provider/idna_function.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package provider 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/hashicorp/terraform-plugin-framework/function" 10 | "golang.org/x/net/idna" 11 | ) 12 | 13 | var _ function.Function = idnaFunction{} 14 | 15 | func NewIdnaFunction() function.Function { 16 | return idnaFunction{} 17 | } 18 | 19 | type idnaFunction struct{} 20 | 21 | func (r idnaFunction) Metadata(_ context.Context, _ function.MetadataRequest, resp *function.MetadataResponse) { 22 | resp.Name = "idna" 23 | } 24 | 25 | func (r idnaFunction) Definition(_ context.Context, _ function.DefinitionRequest, resp *function.DefinitionResponse) { 26 | resp.Definition = function.Definition{ 27 | Summary: "idna function", 28 | Description: "idna converts a IDN domain or domain label to its ASCII form (Punnycode).", 29 | MarkdownDescription: "idna converts a [IDN] domain or domain label to its ASCII form (Punnycode). " + 30 | "For example, `provider::hetznerdns::idna(\"bücher.example.com\")` is " + 31 | "\"xn--bcher-kva.example.com\", and `provider::hetznerdns::idna(\"golang\")` is \"golang\". " + 32 | "If an error is encountered it will return an error and a (partially) processed result.\n\n" + 33 | "[IDN]: https://en.wikipedia.org/wiki/Internationalized_domain_name", 34 | Parameters: []function.Parameter{ 35 | function.StringParameter{ 36 | Name: "domain", 37 | MarkdownDescription: "domain to convert", 38 | }, 39 | }, 40 | Return: function.StringReturn{}, 41 | } 42 | } 43 | 44 | func (r idnaFunction) Run(ctx context.Context, req function.RunRequest, resp *function.RunResponse) { 45 | var ( 46 | domain string 47 | err error 48 | ) 49 | 50 | resp.Error = function.ConcatFuncErrors(req.Arguments.Get(ctx, &domain)) 51 | 52 | if resp.Error != nil { 53 | return 54 | } 55 | 56 | domain, err = idna.New().ToASCII(domain) 57 | if err != nil { 58 | resp.Error = function.NewFuncError("failed to convert domain to ASCII: " + err.Error()) 59 | 60 | return 61 | } 62 | 63 | resp.Error = function.ConcatFuncErrors(resp.Result.Set(ctx, domain)) 64 | } 65 | -------------------------------------------------------------------------------- /internal/provider/idna_function_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/hashicorp/terraform-plugin-testing/helper/resource" 7 | "github.com/hashicorp/terraform-plugin-testing/knownvalue" 8 | "github.com/hashicorp/terraform-plugin-testing/statecheck" 9 | "github.com/hashicorp/terraform-plugin-testing/tfversion" 10 | ) 11 | 12 | func TestIdnaFunction_Valid(t *testing.T) { 13 | t.Parallel() 14 | 15 | resource.UnitTest(t, resource.TestCase{ 16 | TerraformVersionChecks: []tfversion.TerraformVersionCheck{ 17 | tfversion.SkipBelow(tfversion.Version1_8_0), 18 | }, 19 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 20 | Steps: []resource.TestStep{ 21 | { 22 | Config: ` 23 | output "domain" { 24 | value = provider::hetznerdns::idna("bücher.example.com") 25 | } 26 | 27 | output "emoji_domain" { 28 | value = provider::hetznerdns::idna("😂😂👍.com") 29 | }`, 30 | ConfigStateChecks: []statecheck.StateCheck{ 31 | statecheck.ExpectKnownOutputValue("domain", knownvalue.StringExact("xn--bcher-kva.example.com")), 32 | statecheck.ExpectKnownOutputValue("emoji_domain", knownvalue.StringExact("xn--yp8hj1aa.com")), 33 | }, 34 | }, 35 | }, 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /internal/provider/nameserver_data_source.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/germanbrew/terraform-provider-hetznerdns/internal/api" 9 | "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" 10 | "github.com/hashicorp/terraform-plugin-framework/datasource" 11 | "github.com/hashicorp/terraform-plugin-framework/datasource/schema" 12 | "github.com/hashicorp/terraform-plugin-framework/schema/validator" 13 | "github.com/hashicorp/terraform-plugin-framework/types" 14 | ) 15 | 16 | // Ensure provider defined types fully satisfy framework interfaces. 17 | var _ datasource.DataSource = &nameserversDataSource{} 18 | 19 | func NewNameserversDataSource() datasource.DataSource { 20 | return &nameserversDataSource{} 21 | } 22 | 23 | // nameserversDataSource defines the data source implementation. 24 | type nameserversDataSource struct { 25 | provider *providerClient 26 | } 27 | 28 | type singleNameserverDataModel struct { 29 | Name types.String `tfsdk:"name"` 30 | IPV4 types.String `tfsdk:"ipv4"` 31 | IPV6 types.String `tfsdk:"ipv6"` 32 | } 33 | 34 | // nameserversDataSourceModel describes the data source data model. 35 | type nameserversDataSourceModel struct { 36 | Type types.String `tfsdk:"type"` 37 | NS []singleNameserverDataModel `tfsdk:"ns"` 38 | } 39 | 40 | // defaultNameserverType is the default value for the `type` attribute. 41 | const defaultNameserverType = "authoritative" 42 | 43 | func getValidNameserverTypes() []string { 44 | return []string{"authoritative", "secondary", "konsoleh"} 45 | } 46 | 47 | func (d *nameserversDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { 48 | resp.TypeName = req.ProviderTypeName + "_nameservers" 49 | } 50 | 51 | func singleNameserverSchema() map[string]schema.Attribute { 52 | return map[string]schema.Attribute{ 53 | "name": schema.StringAttribute{ 54 | MarkdownDescription: "Name of the name server", 55 | Computed: true, 56 | }, 57 | "ipv4": schema.StringAttribute{ 58 | MarkdownDescription: "IPv4 address of the name server", 59 | Computed: true, 60 | }, 61 | "ipv6": schema.StringAttribute{ 62 | MarkdownDescription: "IPv6 address of the name server", 63 | Computed: true, 64 | }, 65 | } 66 | } 67 | 68 | func (d *nameserversDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { 69 | resp.Schema = schema.Schema{ 70 | // This description is used by the documentation generator and the language server. 71 | MarkdownDescription: "Provides details about name servers used by Hetzner DNS", 72 | 73 | Attributes: map[string]schema.Attribute{ 74 | "type": schema.StringAttribute{ 75 | MarkdownDescription: fmt.Sprintf( 76 | "Type of name servers to get data from. Default: `%s` Possible values: `%s`", 77 | defaultNameserverType, 78 | strings.Join(getValidNameserverTypes(), "`, `")), 79 | Optional: true, 80 | Validators: []validator.String{ 81 | stringvalidator.OneOf(getValidNameserverTypes()...), 82 | }, 83 | }, 84 | "ns": schema.SetNestedAttribute{ 85 | MarkdownDescription: "Name servers", 86 | Computed: true, 87 | NestedObject: schema.NestedAttributeObject{ 88 | Attributes: singleNameserverSchema(), 89 | }, 90 | }, 91 | }, 92 | } 93 | } 94 | 95 | func (d *nameserversDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { 96 | // Prevent panic if the provider has not been configured. 97 | if req.ProviderData == nil { 98 | return 99 | } 100 | 101 | provider, ok := req.ProviderData.(*providerClient) 102 | 103 | if !ok { 104 | resp.Diagnostics.AddError( 105 | "Unexpected Data Source Configure Type", 106 | fmt.Sprintf("Expected *providerClient, got: %T. Please report this issue to the provider developers.", req.ProviderData), 107 | ) 108 | 109 | return 110 | } 111 | 112 | d.provider = provider 113 | } 114 | 115 | func populateNameserverData(data *[]singleNameserverDataModel, nameservers []api.Nameserver) *[]singleNameserverDataModel { 116 | for _, ns := range nameservers { 117 | *data = append(*data, singleNameserverDataModel{ 118 | Name: types.StringValue(ns["name"]), 119 | IPV4: types.StringValue(ns["ipv4"]), 120 | IPV6: types.StringValue(ns["ipv6"]), 121 | }) 122 | } 123 | 124 | return data 125 | } 126 | 127 | func (d *nameserversDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { 128 | var ( 129 | err error 130 | data nameserversDataSourceModel 131 | nameservers []api.Nameserver 132 | ) 133 | 134 | // Read Terraform configuration data into the model 135 | resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) 136 | 137 | if resp.Diagnostics.HasError() { 138 | return 139 | } 140 | 141 | // If the type attribute is not set, set it to the default value 142 | if data.Type.IsNull() { 143 | data.Type = types.StringValue(defaultNameserverType) 144 | } 145 | 146 | // Get the nameservers based on the type 147 | switch data.Type.ValueString() { 148 | case "authoritative": 149 | nameservers, err = api.GetAuthoritativeNameservers(ctx) 150 | case "secondary": 151 | nameservers, err = api.GetSecondaryNameservers(ctx) 152 | case "konsoleh": 153 | nameservers, err = api.GetKonsolehNameservers(ctx) 154 | default: 155 | resp.Diagnostics.AddError( 156 | "Type Error", 157 | fmt.Sprintf("Invalid nameserver type: %s, must be one of %s", 158 | data.Type.String(), 159 | strings.Join(getValidNameserverTypes(), ", "), 160 | ), 161 | ) 162 | 163 | return 164 | } 165 | 166 | if err != nil { 167 | resp.Diagnostics.AddError( 168 | "API Error", 169 | fmt.Sprintf("Error while getting nameservers: %s", err), 170 | ) 171 | 172 | return 173 | } 174 | 175 | // Populate the data model 176 | data.NS = make([]singleNameserverDataModel, 0, len(nameservers)) 177 | populateNameserverData(&data.NS, nameservers) 178 | 179 | // Save data into Terraform state 180 | resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) 181 | } 182 | -------------------------------------------------------------------------------- /internal/provider/nameserver_data_source_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "regexp" 6 | "sort" 7 | "testing" 8 | 9 | "github.com/germanbrew/terraform-provider-hetznerdns/internal/api" 10 | "github.com/hashicorp/terraform-plugin-testing/helper/resource" 11 | "github.com/hashicorp/terraform-plugin-testing/knownvalue" 12 | "github.com/hashicorp/terraform-plugin-testing/statecheck" 13 | ) 14 | 15 | // prepareKnownValues prepares a list of known values for the given nameserver list. 16 | func prepareKnownValues(nameserver []string) []knownvalue.Check { 17 | knownValues := make([]knownvalue.Check, len(nameserver)) 18 | // Sort the list to ensure the order is consistent and matches the output of the data source. 19 | sort.Strings(nameserver) 20 | 21 | for i, ip := range nameserver { 22 | knownValues[i] = knownvalue.StringExact(ip) 23 | } 24 | 25 | return knownValues 26 | } 27 | 28 | func TestAccNameserversDataSource_Valid(t *testing.T) { 29 | ctx, cancel := context.WithCancel(context.Background()) 30 | defer cancel() 31 | 32 | authorizedNameservers, err := api.GetAuthoritativeNameservers(ctx) 33 | if err != nil { 34 | t.Fatalf("error fetching authoritative nameservers: %s", err) 35 | } 36 | 37 | nsNames := make([]knownvalue.Check, len(authorizedNameservers)) 38 | 39 | for i, ns := range authorizedNameservers { 40 | nsNames[i] = knownvalue.StringExact(ns["name"]) 41 | } 42 | 43 | // The IPv4 addresses of the authoritative nameservers to check against. 44 | // https://docs.hetzner.com/dns-console/dns/general/authoritative-name-servers/#new-name-servers-for-robot-and-cloud-console-customers 45 | authorizedNsIPv4 := []string{ 46 | "193.47.99.5", 47 | "213.133.100.98", 48 | "88.198.229.192", 49 | } 50 | 51 | // The IPv6 addresses of the secondary nameservers to check against. 52 | // https://docs.hetzner.com/dns-console/dns/general/authoritative-name-servers/#secondary-dns-servers-old-name-servers-for-robot-customers 53 | secondaryNsIPv6 := []string{ 54 | "2a01:4f8:0:a101::a:1", 55 | "2a01:4f8:0:1::5ddc:2", 56 | "2001:67c:192c::add:a3", 57 | } 58 | 59 | authorizedIPv4 := prepareKnownValues(authorizedNsIPv4) 60 | secondaryIPv6 := prepareKnownValues(secondaryNsIPv6) 61 | 62 | resource.Test(t, resource.TestCase{ 63 | PreCheck: func() { testAccPreCheck(t) }, 64 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 65 | Steps: []resource.TestStep{ 66 | // Read testing 67 | { 68 | Config: testAccNameserversDataSourceConfig, 69 | Check: resource.ComposeAggregateTestCheckFunc( 70 | resource.TestCheckResourceAttr("data.hetznerdns_nameservers.primary", "ns.0.name", authorizedNameservers[0]["name"]), 71 | resource.TestCheckResourceAttrSet("data.hetznerdns_nameservers.primary", "ns.#"), 72 | ), 73 | ConfigStateChecks: []statecheck.StateCheck{ 74 | statecheck.ExpectKnownOutputValue("primary_names", knownvalue.ListExact(nsNames)), 75 | statecheck.ExpectKnownOutputValue("primary_ipv4s", knownvalue.ListExact(authorizedIPv4)), 76 | statecheck.ExpectKnownOutputValue("secondary_ipv6s", knownvalue.ListExact(secondaryIPv6)), 77 | }, 78 | }, 79 | }, 80 | }) 81 | } 82 | 83 | func TestAccNameserversDataSource_InvalidType(t *testing.T) { 84 | resource.Test(t, resource.TestCase{ 85 | PreCheck: func() { testAccPreCheck(t) }, 86 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 87 | Steps: []resource.TestStep{ 88 | // Read testing 89 | { 90 | Config: testAccInvalidTypeNameserversDataSourceConfig, 91 | ExpectError: regexp.MustCompile("Attribute type value must be one of"), 92 | }, 93 | }, 94 | }) 95 | } 96 | 97 | func TestAccNameserversDataSource_DefaultType(t *testing.T) { 98 | ctx, cancel := context.WithCancel(context.Background()) 99 | defer cancel() 100 | 101 | authorizedNameservers, err := api.GetAuthoritativeNameservers(ctx) 102 | if err != nil { 103 | t.Fatalf("error fetching authoritative nameservers: %s", err) 104 | } 105 | 106 | nsNames := make([]knownvalue.Check, len(authorizedNameservers)) 107 | 108 | for i, ns := range authorizedNameservers { 109 | nsNames[i] = knownvalue.StringExact(ns["name"]) 110 | } 111 | 112 | resource.Test(t, resource.TestCase{ 113 | PreCheck: func() { testAccPreCheck(t) }, 114 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 115 | Steps: []resource.TestStep{ 116 | // Read testing 117 | { 118 | Config: testAccDefaultTypeNameserversDataSourceConfig, 119 | Check: resource.ComposeAggregateTestCheckFunc( 120 | resource.TestCheckResourceAttrSet("data.hetznerdns_nameservers.primary", "ns.#"), 121 | ), 122 | ConfigStateChecks: []statecheck.StateCheck{ 123 | statecheck.ExpectKnownOutputValue("primary_names", knownvalue.ListExact(nsNames)), 124 | }, 125 | }, 126 | }, 127 | }) 128 | } 129 | 130 | const testAccNameserversDataSourceConfig = ` 131 | data "hetznerdns_nameservers" "primary" { 132 | type = "authoritative" 133 | } 134 | 135 | data "hetznerdns_nameservers" "secondary" { 136 | type = "secondary" 137 | } 138 | 139 | data "hetznerdns_nameservers" "konsoleh" { 140 | type = "konsoleh" 141 | } 142 | 143 | output "primary_names" { 144 | value = data.hetznerdns_nameservers.primary.ns.*.name 145 | } 146 | 147 | output "primary_ipv4s" { 148 | value = data.hetznerdns_nameservers.primary.ns.*.ipv4 149 | } 150 | 151 | output "secondary_ipv6s" { 152 | value = data.hetznerdns_nameservers.secondary.ns.*.ipv6 153 | } 154 | ` 155 | 156 | const testAccInvalidTypeNameserversDataSourceConfig = ` 157 | data "hetznerdns_nameservers" "invalid" { 158 | type = "invalid_type" 159 | } 160 | ` 161 | 162 | const testAccDefaultTypeNameserversDataSourceConfig = ` 163 | data "hetznerdns_nameservers" "primary" {} 164 | 165 | output "primary_names" { 166 | value = data.hetznerdns_nameservers.primary.ns.*.name 167 | } 168 | ` 169 | -------------------------------------------------------------------------------- /internal/provider/primary_server_resource.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/germanbrew/terraform-provider-hetznerdns/internal/api" 10 | "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" 11 | "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" 12 | "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" 13 | "github.com/hashicorp/terraform-plugin-framework/path" 14 | "github.com/hashicorp/terraform-plugin-framework/resource" 15 | "github.com/hashicorp/terraform-plugin-framework/resource/schema" 16 | "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" 17 | "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" 18 | "github.com/hashicorp/terraform-plugin-framework/schema/validator" 19 | "github.com/hashicorp/terraform-plugin-framework/types" 20 | "github.com/hashicorp/terraform-plugin-log/tflog" 21 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" 22 | ) 23 | 24 | // Ensure provider defined types fully satisfy framework interfaces. 25 | var ( 26 | _ resource.Resource = &primaryServerResource{} 27 | _ resource.ResourceWithImportState = &primaryServerResource{} 28 | ) 29 | 30 | func NewPrimaryServerResource() resource.Resource { 31 | return &primaryServerResource{} 32 | } 33 | 34 | // primaryServerResource defines the resource implementation. 35 | type primaryServerResource struct { 36 | provider *providerClient 37 | } 38 | 39 | // primaryServerResourceModel describes the resource data model. 40 | type primaryServerResourceModel struct { 41 | ID types.String `tfsdk:"id"` 42 | Address types.String `tfsdk:"address"` 43 | Port types.Int64 `tfsdk:"port"` 44 | ZoneID types.String `tfsdk:"zone_id"` 45 | 46 | Timeouts timeouts.Value `tfsdk:"timeouts"` 47 | } 48 | 49 | func (r *primaryServerResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { 50 | resp.TypeName = req.ProviderTypeName + "_primary_server" 51 | } 52 | 53 | func (r *primaryServerResource) Schema(ctx context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { 54 | resp.Schema = schema.Schema{ 55 | MarkdownDescription: "Configure primary server for a domain", 56 | 57 | Attributes: map[string]schema.Attribute{ 58 | "address": schema.StringAttribute{ 59 | Description: "Address of the primary server.", 60 | Required: true, 61 | Validators: []validator.String{ 62 | stringvalidator.LengthAtLeast(1), 63 | }, 64 | }, 65 | "port": schema.Int64Attribute{ 66 | MarkdownDescription: "Port of the primary server.", 67 | Required: true, 68 | Validators: []validator.Int64{ 69 | int64validator.AtLeast(1), 70 | int64validator.AtMost(65535), 71 | }, 72 | }, 73 | "zone_id": schema.StringAttribute{ 74 | MarkdownDescription: "Zone identifier", 75 | Required: true, 76 | PlanModifiers: []planmodifier.String{ 77 | stringplanmodifier.RequiresReplace(), 78 | }, 79 | Validators: []validator.String{ 80 | stringvalidator.LengthAtLeast(1), 81 | }, 82 | }, 83 | "id": schema.StringAttribute{ 84 | Computed: true, 85 | MarkdownDescription: "Zone identifier", 86 | PlanModifiers: []planmodifier.String{ 87 | stringplanmodifier.UseStateForUnknown(), 88 | }, 89 | }, 90 | }, 91 | 92 | Blocks: map[string]schema.Block{ 93 | "timeouts": timeouts.Block(ctx, timeouts.Opts{ 94 | Create: true, 95 | Read: true, 96 | Update: true, 97 | Delete: true, 98 | 99 | CreateDescription: `[Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of 100 | numbers and unit suffixes, such as "30s" or "2h45m". 101 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m`, 102 | DeleteDescription: `[Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of 103 | numbers and unit suffixes, such as "30s" or "2h45m". 104 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m`, 105 | ReadDescription: `[Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of 106 | numbers and unit suffixes, such as "30s" or "2h45m". 107 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m`, 108 | UpdateDescription: `[Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of 109 | numbers and unit suffixes, such as "30s" or "2h45m". 110 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m`, 111 | }), 112 | }, 113 | } 114 | } 115 | 116 | func (r *primaryServerResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { 117 | // Prevent panic if the provider has not been configured. 118 | if req.ProviderData == nil { 119 | return 120 | } 121 | 122 | provider, ok := req.ProviderData.(*providerClient) 123 | 124 | if !ok { 125 | resp.Diagnostics.AddError( 126 | "Unexpected Resource Configure Type", 127 | fmt.Sprintf("Expected *providerClient, got: %T. Please report this issue to the provider developers.", req.ProviderData), 128 | ) 129 | 130 | return 131 | } 132 | 133 | r.provider = provider 134 | } 135 | 136 | func (r *primaryServerResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { 137 | tflog.Trace(ctx, "creating primary server") 138 | 139 | var plan primaryServerResourceModel 140 | 141 | // Read Terraform plan into the model 142 | resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) 143 | 144 | if resp.Diagnostics.HasError() { 145 | return 146 | } 147 | 148 | createTimeout, diags := plan.Timeouts.Create(ctx, 5*time.Minute) 149 | resp.Diagnostics.Append(diags...) 150 | 151 | if resp.Diagnostics.HasError() { 152 | return 153 | } 154 | 155 | var ( 156 | err error 157 | server *api.PrimaryServer 158 | retries int64 159 | ) 160 | 161 | serverRequest := api.CreatePrimaryServerRequest{ 162 | ZoneID: plan.ZoneID.ValueString(), 163 | Address: plan.Address.ValueString(), 164 | Port: plan.Port.ValueInt64(), 165 | } 166 | 167 | err = retry.RetryContext(ctx, createTimeout, func() *retry.RetryError { 168 | retries++ 169 | 170 | server, err = r.provider.apiClient.CreatePrimaryServer(ctx, serverRequest) 171 | if err != nil { 172 | if retries == r.provider.maxRetries { 173 | return retry.NonRetryableError(err) 174 | } 175 | 176 | return retry.RetryableError(err) 177 | } 178 | 179 | return nil 180 | }) 181 | if err != nil { 182 | resp.Diagnostics.AddError("API Error", fmt.Sprintf("creating primary server: %s", err)) 183 | 184 | return 185 | } 186 | 187 | plan.ID = types.StringValue(server.ID) 188 | 189 | // Save plan into Terraform state 190 | resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) 191 | } 192 | 193 | func (r *primaryServerResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { 194 | tflog.Trace(ctx, "reading primary server") 195 | 196 | var state primaryServerResourceModel 197 | 198 | // Read Terraform prior state into the model 199 | resp.Diagnostics.Append(req.State.Get(ctx, &state)...) 200 | 201 | if resp.Diagnostics.HasError() { 202 | return 203 | } 204 | 205 | readTimeout, diags := state.Timeouts.Read(ctx, 5*time.Minute) 206 | resp.Diagnostics.Append(diags...) 207 | 208 | if resp.Diagnostics.HasError() { 209 | return 210 | } 211 | 212 | var ( 213 | err error 214 | server *api.PrimaryServer 215 | retries int64 216 | ) 217 | 218 | err = retry.RetryContext(ctx, readTimeout, func() *retry.RetryError { 219 | retries++ 220 | 221 | server, err = r.provider.apiClient.GetPrimaryServer(ctx, state.ID.ValueString()) 222 | if err != nil { 223 | if retries == r.provider.maxRetries { 224 | return retry.NonRetryableError(err) 225 | } 226 | 227 | return retry.RetryableError(err) 228 | } 229 | 230 | return nil 231 | }) 232 | if err != nil && !errors.Is(err, api.ErrNotFound) { 233 | resp.Diagnostics.AddError("API Error", fmt.Sprintf("read primary server: %s", err)) 234 | 235 | return 236 | } 237 | 238 | if server == nil { 239 | resp.State.RemoveResource(ctx) 240 | 241 | return 242 | } 243 | 244 | state.ID = types.StringValue(server.ID) 245 | state.Address = types.StringValue(server.Address) 246 | state.ZoneID = types.StringValue(server.ZoneID) 247 | state.Port = types.Int64Value(server.Port) 248 | 249 | // Save updated state into Terraform state 250 | resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) 251 | } 252 | 253 | func (r *primaryServerResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { 254 | tflog.Trace(ctx, "updating primary server") 255 | 256 | var plan, state primaryServerResourceModel 257 | 258 | resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) 259 | resp.Diagnostics.Append(req.State.Get(ctx, &state)...) 260 | 261 | if resp.Diagnostics.HasError() { 262 | return 263 | } 264 | 265 | if !plan.Address.Equal(state.Address) || !plan.Port.Equal(state.Port) { 266 | updateTimeout, diags := plan.Timeouts.Update(ctx, 5*time.Minute) 267 | resp.Diagnostics.Append(diags...) 268 | 269 | if resp.Diagnostics.HasError() { 270 | return 271 | } 272 | 273 | var ( 274 | err error 275 | retries int64 276 | ) 277 | 278 | server := api.PrimaryServer{ 279 | ID: state.ID.ValueString(), 280 | Address: plan.Address.ValueString(), 281 | Port: plan.Port.ValueInt64(), 282 | ZoneID: plan.ZoneID.ValueString(), 283 | } 284 | 285 | err = retry.RetryContext(ctx, updateTimeout, func() *retry.RetryError { 286 | retries++ 287 | 288 | _, err = r.provider.apiClient.UpdatePrimaryServer(ctx, server) 289 | if err != nil { 290 | if retries == r.provider.maxRetries { 291 | return retry.NonRetryableError(err) 292 | } 293 | 294 | return retry.RetryableError(err) 295 | } 296 | 297 | return nil 298 | }) 299 | if err != nil { 300 | resp.Diagnostics.AddError("API Error", fmt.Sprintf("update primary server %s: %s", state.ID, err)) 301 | 302 | return 303 | } 304 | } 305 | 306 | // Save updated data into Terraform state 307 | resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) 308 | } 309 | 310 | func (r *primaryServerResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { 311 | tflog.Trace(ctx, "deleting resource record") 312 | 313 | var state primaryServerResourceModel 314 | 315 | // Read Terraform prior state into the model 316 | resp.Diagnostics.Append(req.State.Get(ctx, &state)...) 317 | 318 | if resp.Diagnostics.HasError() { 319 | return 320 | } 321 | 322 | deleteTimeout, diags := state.Timeouts.Delete(ctx, 5*time.Minute) 323 | resp.Diagnostics.Append(diags...) 324 | 325 | if resp.Diagnostics.HasError() { 326 | return 327 | } 328 | 329 | var ( 330 | err error 331 | retries int64 332 | ) 333 | 334 | err = retry.RetryContext(ctx, deleteTimeout, func() *retry.RetryError { 335 | retries++ 336 | 337 | err = r.provider.apiClient.DeletePrimaryServer(ctx, state.ID.ValueString()) 338 | if err != nil { 339 | if retries == r.provider.maxRetries { 340 | return retry.NonRetryableError(err) 341 | } 342 | 343 | return retry.RetryableError(err) 344 | } 345 | 346 | return nil 347 | }) 348 | if err != nil { 349 | resp.Diagnostics.AddError("API Error", fmt.Sprintf("deleting primary server %s: %s", state.ID, err)) 350 | 351 | return 352 | } 353 | } 354 | 355 | func (r *primaryServerResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { 356 | resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) 357 | } 358 | -------------------------------------------------------------------------------- /internal/provider/primary_server_resource_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "regexp" 8 | "strconv" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/germanbrew/terraform-provider-hetznerdns/internal/api" 13 | "github.com/germanbrew/terraform-provider-hetznerdns/internal/utils" 14 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/logging" 15 | "github.com/hashicorp/terraform-plugin-testing/helper/acctest" 16 | "github.com/hashicorp/terraform-plugin-testing/helper/resource" 17 | ) 18 | 19 | func TestAccPrimaryServer_OnePrimaryServersResources(t *testing.T) { 20 | aZoneName := acctest.RandString(10) + ".online" 21 | aZoneTTL := 3600 22 | 23 | psAddress := "1.1.0.0" 24 | psPort := 53 25 | 26 | resource.Test(t, resource.TestCase{ 27 | PreCheck: func() { testAccPreCheck(t) }, 28 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 29 | Steps: []resource.TestStep{ 30 | // Create and Read testing 31 | { 32 | Config: strings.Join( 33 | []string{ 34 | testAccZoneResourceConfig("test", aZoneName, aZoneTTL), 35 | testAccPrimaryServerResourceConfigCreate("test", psAddress, psPort), 36 | }, "\n", 37 | ), 38 | Check: resource.ComposeAggregateTestCheckFunc( 39 | resource.TestCheckResourceAttrSet("hetznerdns_primary_server.test", "id"), 40 | resource.TestCheckResourceAttr("hetznerdns_primary_server.test", "address", psAddress), 41 | resource.TestCheckResourceAttr("hetznerdns_primary_server.test", "port", strconv.Itoa(psPort)), 42 | ), 43 | }, 44 | // ImportState testing 45 | { 46 | ResourceName: "hetznerdns_primary_server.test", 47 | ImportState: true, 48 | ImportStateVerify: true, 49 | }, 50 | // Update and Read testing 51 | { 52 | Config: strings.Join( 53 | []string{ 54 | testAccZoneResourceConfig("test", aZoneName, aZoneTTL), 55 | testAccPrimaryServerResourceConfigCreate("test", psAddress, psPort*2), 56 | }, "\n", 57 | ), 58 | Check: resource.ComposeAggregateTestCheckFunc( 59 | resource.TestCheckResourceAttr("hetznerdns_primary_server.test", "port", strconv.Itoa(psPort*2)), 60 | ), 61 | }, 62 | // Delete testing automatically occurs in TestCase 63 | }, 64 | }) 65 | } 66 | 67 | func TestAccPrimaryServer_Invalid(t *testing.T) { 68 | aZoneName := acctest.RandString(10) + ".online" 69 | aZoneTTL := 3600 70 | 71 | psAddress := "-" 72 | psPort := 53 73 | 74 | resource.Test(t, resource.TestCase{ 75 | PreCheck: func() { testAccPreCheck(t) }, 76 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 77 | Steps: []resource.TestStep{ 78 | // Create and Read testing 79 | { 80 | Config: strings.Join( 81 | []string{ 82 | testAccZoneResourceConfig("test", aZoneName, aZoneTTL), 83 | testAccPrimaryServerResourceConfigCreate("test", psAddress, psPort), 84 | }, "\n", 85 | ), 86 | ExpectError: regexp.MustCompile("422 Unprocessable Entity"), 87 | }, 88 | // Delete testing automatically occurs in TestCase 89 | }, 90 | }) 91 | } 92 | 93 | func TestAccPrimaryServer_InvalidPort(t *testing.T) { 94 | aZoneName := acctest.RandString(10) + ".online" 95 | aZoneTTL := 3600 96 | 97 | psAddress := "-" 98 | psPort := 666666 99 | 100 | resource.Test(t, resource.TestCase{ 101 | PreCheck: func() { testAccPreCheck(t) }, 102 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 103 | Steps: []resource.TestStep{ 104 | // Create and Read testing 105 | { 106 | Config: strings.Join( 107 | []string{ 108 | testAccZoneResourceConfig("test", aZoneName, aZoneTTL), 109 | testAccPrimaryServerResourceConfigCreate("test", psAddress, psPort), 110 | }, "\n", 111 | ), 112 | ExpectError: regexp.MustCompile("Attribute port value must be at most 65535, got: " + strconv.Itoa(psPort)), 113 | }, 114 | // Delete testing automatically occurs in TestCase 115 | }, 116 | }) 117 | } 118 | 119 | func TestAccPrimaryServer_TwoPrimaryServersResources(t *testing.T) { 120 | aZoneName := acctest.RandString(10) + ".online" 121 | aZoneTTL := 3600 122 | 123 | ps1Address := "1.1.0.0" 124 | ps1Port := 53 125 | 126 | ps2Address := "2.2.0.0" 127 | ps2Port := 53 128 | 129 | resource.Test(t, resource.TestCase{ 130 | PreCheck: func() { testAccPreCheck(t) }, 131 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 132 | Steps: []resource.TestStep{ 133 | // Create and Read testing 134 | { 135 | Config: strings.Join( 136 | []string{ 137 | testAccZoneResourceConfig("test", aZoneName, aZoneTTL), 138 | testAccPrimaryServerResourceConfigCreate("ps1", ps1Address, ps1Port), 139 | testAccPrimaryServerResourceConfigCreate("ps2", ps2Address, ps2Port), 140 | }, "\n", 141 | ), 142 | Check: resource.ComposeAggregateTestCheckFunc( 143 | resource.TestCheckResourceAttrSet("hetznerdns_primary_server.ps1", "id"), 144 | resource.TestCheckResourceAttrSet("hetznerdns_primary_server.ps2", "id"), 145 | ), 146 | }, 147 | // Delete testing automatically occurs in TestCase 148 | }, 149 | }) 150 | } 151 | 152 | func TestAccPrimaryServer_StalePrimaryServersResources(t *testing.T) { 153 | aZoneName := acctest.RandString(10) + ".online" 154 | aZoneTTL := 3600 155 | 156 | psAddress := "1.1.0.0" 157 | psPort := 53 158 | 159 | resource.Test(t, resource.TestCase{ 160 | PreCheck: func() { testAccPreCheck(t) }, 161 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 162 | Steps: []resource.TestStep{ 163 | // Create and Read testing 164 | { 165 | Config: strings.Join( 166 | []string{ 167 | testAccZoneResourceConfig("test", aZoneName, aZoneTTL), 168 | testAccPrimaryServerResourceConfigCreate("test", psAddress, psPort), 169 | }, "\n", 170 | ), 171 | Check: resource.ComposeAggregateTestCheckFunc( 172 | resource.TestCheckResourceAttrSet("hetznerdns_primary_server.test", "id"), 173 | resource.TestCheckResourceAttr("hetznerdns_primary_server.test", "address", psAddress), 174 | resource.TestCheckResourceAttr("hetznerdns_primary_server.test", "port", strconv.Itoa(psPort)), 175 | ), 176 | }, 177 | // ImportState testing 178 | { 179 | ResourceName: "hetznerdns_primary_server.test", 180 | ImportState: true, 181 | ImportStateVerify: true, 182 | }, 183 | // Update and Read testing 184 | { 185 | Config: strings.Join( 186 | []string{ 187 | testAccZoneResourceConfig("test", aZoneName, aZoneTTL), 188 | testAccPrimaryServerResourceConfigCreate("test", psAddress, psPort*2), 189 | }, "\n", 190 | ), 191 | Check: resource.ComposeAggregateTestCheckFunc( 192 | resource.TestCheckResourceAttr("hetznerdns_primary_server.test", "port", strconv.Itoa(psPort*2)), 193 | ), 194 | }, 195 | // Remove primary server from Hetzner DNS and check if it will be recreated by Terraform 196 | { 197 | PreConfig: func() { 198 | ctx, cancel := context.WithCancel(context.Background()) 199 | defer cancel() 200 | 201 | var ( 202 | data hetznerDNSProviderModel 203 | apiToken string 204 | apiClient *api.Client 205 | err error 206 | ) 207 | 208 | apiToken = utils.ConfigureStringAttribute(data.ApiToken, "HETZNER_DNS_TOKEN", "") 209 | httpClient := logging.NewLoggingHTTPTransport(http.DefaultTransport) 210 | apiClient, err = api.New("https://dns.hetzner.com", apiToken, httpClient) 211 | if err != nil { 212 | t.Fatalf("Error while creating API apiClient: %s", err) 213 | } 214 | zone, err := apiClient.GetZoneByName(ctx, aZoneName) 215 | if err != nil { 216 | t.Fatalf("Error while fetching zone: %s", err) 217 | } else if zone == nil { 218 | t.Fatalf("Zone %s not found", aZoneName) 219 | } 220 | 221 | primaryServer, err := apiClient.GetPrimaryServers(ctx, zone.ID) 222 | if err != nil { 223 | t.Fatalf("Error while fetching primary server: %s", err) 224 | } else if primaryServer == nil { 225 | t.Fatalf("Primary server %s not found", zone.ID) 226 | } 227 | 228 | err = apiClient.DeletePrimaryServer(ctx, primaryServer[0].ID) 229 | if err != nil { 230 | t.Fatalf("Error while deleting primary server: %s", err) 231 | } 232 | }, 233 | // Check if the record is recreated 234 | // ExpectNonEmptyPlan: true, 235 | RefreshState: true, 236 | ExpectError: regexp.MustCompile("hetznerdns_primary_server.test will be created"), 237 | }, 238 | // Delete testing automatically occurs in TestCase 239 | }, 240 | }) 241 | } 242 | 243 | func testAccPrimaryServerResourceConfigCreate(resourceName, psAddress string, psPort int) string { 244 | return fmt.Sprintf(` 245 | resource "hetznerdns_primary_server" "%s" { 246 | zone_id = hetznerdns_zone.test.id 247 | address = %q 248 | port = %d 249 | } 250 | `, resourceName, psAddress, psPort) 251 | } 252 | -------------------------------------------------------------------------------- /internal/provider/provider.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | 10 | "github.com/germanbrew/terraform-provider-hetznerdns/internal/api" 11 | "github.com/germanbrew/terraform-provider-hetznerdns/internal/utils" 12 | "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" 13 | "github.com/hashicorp/terraform-plugin-framework/datasource" 14 | "github.com/hashicorp/terraform-plugin-framework/function" 15 | "github.com/hashicorp/terraform-plugin-framework/path" 16 | "github.com/hashicorp/terraform-plugin-framework/provider" 17 | "github.com/hashicorp/terraform-plugin-framework/provider/schema" 18 | "github.com/hashicorp/terraform-plugin-framework/resource" 19 | "github.com/hashicorp/terraform-plugin-framework/schema/validator" 20 | "github.com/hashicorp/terraform-plugin-framework/types" 21 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/logging" 22 | ) 23 | 24 | // Ensure ScaffoldingProvider satisfies various provider interfaces. 25 | var ( 26 | _ provider.Provider = &hetznerDNSProvider{} 27 | _ provider.ProviderWithFunctions = &hetznerDNSProvider{} 28 | ) 29 | 30 | type hetznerDNSProvider struct { 31 | version string 32 | } 33 | 34 | type hetznerDNSProviderModel struct { 35 | ApiToken types.String `tfsdk:"api_token"` 36 | MaxRetries types.Int64 `tfsdk:"max_retries"` 37 | EnableTxtFormatter types.Bool `tfsdk:"enable_txt_formatter"` 38 | EnableIPValidation types.Bool `tfsdk:"enable_ip_validation"` 39 | } 40 | 41 | type providerClient struct { 42 | apiClient *api.Client 43 | maxRetries int64 44 | txtFormatter bool 45 | ipValidation bool 46 | } 47 | 48 | func (p *hetznerDNSProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) { 49 | resp.TypeName = "hetznerdns" 50 | resp.Version = p.version 51 | } 52 | 53 | func (p *hetznerDNSProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) { 54 | resp.Schema = schema.Schema{ 55 | Description: "This providers helps you automate management of DNS zones and records at Hetzner DNS.", 56 | Attributes: map[string]schema.Attribute{ 57 | "api_token": schema.StringAttribute{ 58 | Description: "The Hetzner DNS API token. You can pass it using the env variable `HETZNER_DNS_TOKEN` as well. " + 59 | "The old env variable `HETZNER_DNS_API_TOKEN` is deprecated and will be removed in a future release.", 60 | Optional: true, 61 | Sensitive: true, 62 | }, 63 | "max_retries": schema.Int64Attribute{ 64 | Description: "`Default: 1` The maximum number of retries to perform when an API request fails. " + 65 | "You can pass it using the env variable `HETZNER_DNS_MAX_RETRIES` as well.", 66 | Optional: true, 67 | Validators: []validator.Int64{ 68 | int64validator.AtLeast(0), 69 | }, 70 | }, 71 | "enable_txt_formatter": schema.BoolAttribute{ 72 | Description: "`Default: true` Toggles the automatic formatter for TXT record values. " + 73 | "Values greater than 255 bytes get split into multiple quoted chunks " + 74 | "([RFC4408](https://datatracker.ietf.org/doc/html/rfc4408#section-3.1.3)). " + 75 | "You can pass it using the env variable `HETZNER_DNS_ENABLE_TXT_FORMATTER` as well.", 76 | Optional: true, 77 | }, 78 | "enable_ip_validation": schema.BoolAttribute{ 79 | Description: "`Default: true` Toggles the validation of IP addresses in A and AAAA records. " + 80 | "You can pass it using the env variable `HETZNER_DNS_ENABLE_IP_VALIDATION` as well.", 81 | Optional: true, 82 | }, 83 | }, 84 | } 85 | } 86 | 87 | // Configure configures the provider. 88 | func (p *hetznerDNSProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { 89 | var ( 90 | data hetznerDNSProviderModel 91 | apiToken string 92 | err error 93 | ) 94 | 95 | client := &providerClient{} 96 | 97 | resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) 98 | 99 | if resp.Diagnostics.HasError() { 100 | return 101 | } 102 | 103 | apiToken = utils.ConfigureStringAttribute(data.ApiToken, "HETZNER_DNS_TOKEN", "") 104 | // Still support the deprecated env var for now but show a warning if it's used. 105 | if apiToken == "" { 106 | apiToken = os.Getenv("HETZNER_DNS_API_TOKEN") 107 | if apiToken != "" { 108 | resp.Diagnostics.AddWarning("Deprecated API Token Environment Variable", 109 | "The environment variable `HETZNER_DNS_API_TOKEN` is deprecated and will be removed in a future release. "+ 110 | "Please use `HETZNER_DNS_TOKEN` instead.", 111 | ) 112 | } 113 | } 114 | 115 | if apiToken == "" { 116 | resp.Diagnostics.AddAttributeError(path.Root("api_token"), "Missing API Token Configuration", 117 | "While configuring the client, the API token was not found in the HETZNER_DNS_TOKEN environment variable or client configuration block "+ 118 | "api_token attribute.", 119 | ) 120 | } 121 | 122 | client.maxRetries, err = utils.ConfigureInt64Attribute(data.MaxRetries, "HETZNER_DNS_MAX_RETRIES", 1) 123 | if err != nil { 124 | resp.Diagnostics.AddAttributeError(path.Root("max_retries"), "must be an integer", err.Error()) 125 | } 126 | 127 | client.txtFormatter, err = utils.ConfigureBoolAttribute(data.EnableTxtFormatter, "HETZNER_DNS_ENABLE_TXT_FORMATTER", true) 128 | if err != nil { 129 | resp.Diagnostics.AddAttributeError(path.Root("enable_txt_formatter"), "must be a boolean", err.Error()) 130 | } 131 | 132 | client.ipValidation, err = utils.ConfigureBoolAttribute(data.EnableIPValidation, "HETZNER_DNS_ENABLE_IP_VALIDATION", true) 133 | if err != nil { 134 | resp.Diagnostics.AddAttributeError(path.Root("enable_ip_validation"), "must be a boolean", err.Error()) 135 | } 136 | 137 | if resp.Diagnostics.HasError() { 138 | return 139 | } 140 | 141 | httpClient := logging.NewLoggingHTTPTransport(http.DefaultTransport) 142 | 143 | client.apiClient, err = api.New("https://dns.hetzner.com", apiToken, httpClient) 144 | if err != nil { 145 | resp.Diagnostics.AddError("API error while configuring client", fmt.Sprintf("Error while creating API apiClient: %s", err)) 146 | 147 | return 148 | } 149 | 150 | client.apiClient.SetUserAgent(fmt.Sprintf("terraform-client-hetznerdns/%s (+https://github.com/germanbrew/terraform-client-hetznerdns) ", p.version)) 151 | 152 | if _, err = client.apiClient.GetZones(ctx); err != nil && !errors.Is(err, api.ErrNotFound) { 153 | resp.Diagnostics.AddError("API error", fmt.Sprintf("Error while fetching zones: %s", err)) 154 | 155 | return 156 | } 157 | 158 | resp.DataSourceData = client 159 | resp.ResourceData = client 160 | } 161 | 162 | func (p *hetznerDNSProvider) Resources(_ context.Context) []func() resource.Resource { 163 | return []func() resource.Resource{ 164 | NewPrimaryServerResource, 165 | NewRecordResource, 166 | NewZoneResource, 167 | } 168 | } 169 | 170 | func (p *hetznerDNSProvider) DataSources(_ context.Context) []func() datasource.DataSource { 171 | return []func() datasource.DataSource{ 172 | NewZoneDataSource, 173 | NewRecordsDataSource, 174 | NewNameserversDataSource, 175 | } 176 | } 177 | 178 | func (p *hetznerDNSProvider) Functions(_ context.Context) []func() function.Function { 179 | return []func() function.Function{ 180 | NewIdnaFunction, 181 | } 182 | } 183 | 184 | func New(version string) func() provider.Provider { 185 | return func() provider.Provider { 186 | return &hetznerDNSProvider{ 187 | version: version, 188 | } 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /internal/provider/provider_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/hashicorp/terraform-plugin-framework/providerserver" 8 | "github.com/hashicorp/terraform-plugin-go/tfprotov6" 9 | ) 10 | 11 | // testAccProtoV6ProviderFactories are used to instantiate a provider during 12 | // acceptance testing. The factory function will be invoked for every Terraform 13 | // CLI command executed to create a provider server to which the CLI can 14 | // reattach. 15 | // 16 | //nolint:gochecknoglobals 17 | var testAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){ 18 | "hetznerdns": providerserver.NewProtocol6WithError(New("test")()), 19 | } 20 | 21 | func testAccPreCheck(t *testing.T) { 22 | if v := os.Getenv("HETZNER_DNS_TOKEN"); v == "" { 23 | t.Fatal("HETZNER_DNS_TOKEN must be set for acceptance tests") 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /internal/provider/record_resource.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/germanbrew/terraform-provider-hetznerdns/internal/api" 10 | "github.com/germanbrew/terraform-provider-hetznerdns/internal/utils" 11 | "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" 12 | "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" 13 | "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" 14 | "github.com/hashicorp/terraform-plugin-framework/path" 15 | "github.com/hashicorp/terraform-plugin-framework/resource" 16 | "github.com/hashicorp/terraform-plugin-framework/resource/schema" 17 | "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" 18 | "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" 19 | "github.com/hashicorp/terraform-plugin-framework/schema/validator" 20 | "github.com/hashicorp/terraform-plugin-framework/types" 21 | "github.com/hashicorp/terraform-plugin-log/tflog" 22 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" 23 | ) 24 | 25 | // Ensure provider defined types fully satisfy framework interfaces. 26 | var ( 27 | _ resource.Resource = &recordResource{} 28 | _ resource.ResourceWithImportState = &recordResource{} 29 | ) 30 | 31 | func NewRecordResource() resource.Resource { 32 | return &recordResource{} 33 | } 34 | 35 | // recordResource defines the resource implementation. 36 | type recordResource struct { 37 | provider *providerClient 38 | } 39 | 40 | // recordResourceModel describes the resource data model. 41 | type recordResourceModel struct { 42 | ID types.String `tfsdk:"id"` 43 | ZoneID types.String `tfsdk:"zone_id"` 44 | Name types.String `tfsdk:"name"` 45 | Type types.String `tfsdk:"type"` 46 | Value types.String `tfsdk:"value"` 47 | TTL types.Int64 `tfsdk:"ttl"` 48 | 49 | Timeouts timeouts.Value `tfsdk:"timeouts"` 50 | } 51 | 52 | func (r *recordResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { 53 | resp.TypeName = req.ProviderTypeName + "_record" 54 | } 55 | 56 | func (r *recordResource) Schema(ctx context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { 57 | resp.Schema = schema.Schema{ 58 | // This description is used by the documentation generator and the language server. 59 | MarkdownDescription: "Provides a Hetzner DNS Zone resource to create, update and delete DNS Zones.", 60 | 61 | Attributes: map[string]schema.Attribute{ 62 | "zone_id": schema.StringAttribute{ 63 | Description: "ID of the DNS zone to create the record in.", 64 | Required: true, 65 | PlanModifiers: []planmodifier.String{ 66 | stringplanmodifier.RequiresReplace(), 67 | }, 68 | Validators: []validator.String{ 69 | stringvalidator.LengthAtLeast(1), 70 | }, 71 | }, 72 | "type": schema.StringAttribute{ 73 | MarkdownDescription: "Type of this DNS record " + 74 | "([See supported types](https://docs.hetzner.com/dns-console/dns/general/supported-dns-record-types/))", 75 | Required: true, 76 | Validators: []validator.String{ 77 | stringvalidator.LengthAtLeast(1), 78 | }, 79 | }, 80 | "name": schema.StringAttribute{ 81 | MarkdownDescription: "Name of the DNS record to create", 82 | Required: true, 83 | Validators: []validator.String{ 84 | stringvalidator.LengthAtLeast(1), 85 | }, 86 | }, 87 | "value": schema.StringAttribute{ 88 | Description: "The value of the record (e.g. 192.168.1.1)", 89 | MarkdownDescription: "The value of the record (e.g. `192.168.1.1`)", 90 | Required: true, 91 | Validators: []validator.String{ 92 | stringvalidator.LengthAtLeast(1), 93 | }, 94 | }, 95 | "ttl": schema.Int64Attribute{ 96 | MarkdownDescription: "Time to live of this record", 97 | Optional: true, 98 | Validators: []validator.Int64{ 99 | int64validator.AtLeast(0), 100 | }, 101 | }, 102 | "id": schema.StringAttribute{ 103 | Computed: true, 104 | MarkdownDescription: "Zone identifier", 105 | PlanModifiers: []planmodifier.String{ 106 | stringplanmodifier.UseStateForUnknown(), 107 | }, 108 | }, 109 | }, 110 | 111 | Blocks: map[string]schema.Block{ 112 | "timeouts": timeouts.Block(ctx, timeouts.Opts{ 113 | Create: true, 114 | Read: true, 115 | Update: true, 116 | Delete: true, 117 | 118 | CreateDescription: `[Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of 119 | numbers and unit suffixes, such as "30s" or "2h45m". 120 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m`, 121 | DeleteDescription: `[Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of 122 | numbers and unit suffixes, such as "30s" or "2h45m". 123 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m`, 124 | ReadDescription: `[Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of 125 | numbers and unit suffixes, such as "30s" or "2h45m". 126 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m`, 127 | UpdateDescription: `[Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of 128 | numbers and unit suffixes, such as "30s" or "2h45m". 129 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m`, 130 | }), 131 | }, 132 | } 133 | } 134 | 135 | func (r *recordResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { 136 | // Prevent panic if the provider has not been configured. 137 | if req.ProviderData == nil { 138 | return 139 | } 140 | 141 | provider, ok := req.ProviderData.(*providerClient) 142 | 143 | if !ok { 144 | resp.Diagnostics.AddError( 145 | "Unexpected Resource Configure Type", 146 | fmt.Sprintf("Expected *providerClient, got: %T. Please report this issue to the provider developers.", req.ProviderData), 147 | ) 148 | 149 | return 150 | } 151 | 152 | r.provider = provider 153 | } 154 | 155 | func (r *recordResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { 156 | tflog.Trace(ctx, "create resource record") 157 | 158 | var plan recordResourceModel 159 | 160 | // Read Terraform plan into the model 161 | resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) 162 | 163 | if resp.Diagnostics.HasError() { 164 | return 165 | } 166 | 167 | createTimeout, diags := plan.Timeouts.Create(ctx, 5*time.Minute) 168 | resp.Diagnostics.Append(diags...) 169 | 170 | if resp.Diagnostics.HasError() { 171 | return 172 | } 173 | 174 | value := plan.Value.ValueString() 175 | if plan.Type.ValueString() == "TXT" && r.provider.txtFormatter { 176 | value = utils.PlainToTXTRecordValue(value) 177 | if plan.Value.ValueString() != value { 178 | tflog.Debug(ctx, fmt.Sprintf("split TXT record value %d chunks: %q", len(value), value)) 179 | } 180 | } 181 | 182 | if (plan.Type.ValueString() == "A" || plan.Type.ValueString() == "AAAA") && r.provider.ipValidation { 183 | err := utils.CheckIPAddress(value) 184 | if err != nil { 185 | resp.Diagnostics.AddError("Invalid IP address", err.Error()) 186 | 187 | return 188 | } 189 | } 190 | 191 | var ( 192 | err error 193 | record *api.Record 194 | retries int64 195 | ) 196 | 197 | recordRequest := api.CreateRecordOpts{ 198 | ZoneID: plan.ZoneID.ValueString(), 199 | Name: plan.Name.ValueString(), 200 | Type: plan.Type.ValueString(), 201 | Value: value, 202 | TTL: plan.TTL.ValueInt64Pointer(), 203 | } 204 | 205 | err = retry.RetryContext(ctx, createTimeout, func() *retry.RetryError { 206 | retries++ 207 | 208 | record, err = r.provider.apiClient.CreateRecord(ctx, recordRequest) 209 | if err != nil { 210 | if retries == r.provider.maxRetries { 211 | return retry.NonRetryableError(err) 212 | } 213 | 214 | return retry.RetryableError(err) 215 | } 216 | 217 | return nil 218 | }) 219 | if err != nil { 220 | resp.Diagnostics.AddError("API Error", fmt.Sprintf("creating record: %s", err)) 221 | 222 | return 223 | } 224 | 225 | plan.ID = types.StringValue(record.ID) 226 | 227 | // Save plan into Terraform state 228 | resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) 229 | } 230 | 231 | func (r *recordResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { 232 | tflog.Trace(ctx, "read resource record") 233 | 234 | var state recordResourceModel 235 | 236 | // Read Terraform prior state into the model 237 | resp.Diagnostics.Append(req.State.Get(ctx, &state)...) 238 | 239 | if resp.Diagnostics.HasError() { 240 | return 241 | } 242 | 243 | readTimeout, diags := state.Timeouts.Read(ctx, 5*time.Minute) 244 | resp.Diagnostics.Append(diags...) 245 | 246 | if resp.Diagnostics.HasError() { 247 | return 248 | } 249 | 250 | var ( 251 | err error 252 | record *api.Record 253 | retries int64 254 | ) 255 | 256 | err = retry.RetryContext(ctx, readTimeout, func() *retry.RetryError { 257 | retries++ 258 | 259 | record, err = r.provider.apiClient.GetRecord(ctx, state.ID.ValueString()) 260 | if err != nil { 261 | if retries == r.provider.maxRetries { 262 | return retry.NonRetryableError(err) 263 | } 264 | 265 | return retry.RetryableError(err) 266 | } 267 | 268 | return nil 269 | }) 270 | if err != nil && !errors.Is(err, api.ErrNotFound) { 271 | resp.Diagnostics.AddError("API Error", fmt.Sprintf("read record: %s", err)) 272 | 273 | return 274 | } 275 | 276 | if record == nil { 277 | resp.State.RemoveResource(ctx) 278 | 279 | return 280 | } 281 | 282 | if record.Type == "TXT" && r.provider.txtFormatter { 283 | record.Value = utils.TXTRecordToPlainValue(record.Value) 284 | } 285 | 286 | state.Name = types.StringValue(record.Name) 287 | state.TTL = types.Int64PointerValue(record.TTL) 288 | state.ZoneID = types.StringValue(record.ZoneID) 289 | state.Type = types.StringValue(record.Type) 290 | state.Value = types.StringValue(record.Value) 291 | state.ID = types.StringValue(record.ID) 292 | 293 | // Save updated state into Terraform state 294 | resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) 295 | } 296 | 297 | func (r *recordResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { 298 | tflog.Trace(ctx, "updating resource record") 299 | 300 | var plan, state recordResourceModel 301 | 302 | resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) 303 | resp.Diagnostics.Append(req.State.Get(ctx, &state)...) 304 | 305 | if resp.Diagnostics.HasError() { 306 | return 307 | } 308 | 309 | value := plan.Value.ValueString() 310 | if plan.Type.ValueString() == "TXT" && r.provider.txtFormatter { 311 | value = utils.PlainToTXTRecordValue(value) 312 | } 313 | 314 | if (plan.Type.ValueString() == "A" || plan.Type.ValueString() == "AAAA") && r.provider.ipValidation { 315 | err := utils.CheckIPAddress(value) 316 | if err != nil { 317 | resp.Diagnostics.AddError("Invalid IP address", err.Error()) 318 | 319 | return 320 | } 321 | } 322 | 323 | if !plan.Name.Equal(state.Name) || !plan.TTL.Equal(state.TTL) || !plan.Type.Equal(state.Type) || !plan.Value.Equal(state.Value) { 324 | updateTimeout, diags := plan.Timeouts.Update(ctx, 5*time.Minute) 325 | resp.Diagnostics.Append(diags...) 326 | 327 | if resp.Diagnostics.HasError() { 328 | return 329 | } 330 | 331 | var ( 332 | err error 333 | retries int64 334 | ) 335 | 336 | record := api.Record{ 337 | ID: state.ID.ValueString(), 338 | Name: plan.Name.ValueString(), 339 | Type: plan.Type.ValueString(), 340 | Value: value, 341 | TTL: plan.TTL.ValueInt64Pointer(), 342 | ZoneID: plan.ZoneID.ValueString(), 343 | } 344 | 345 | err = retry.RetryContext(ctx, updateTimeout, func() *retry.RetryError { 346 | retries++ 347 | 348 | _, err = r.provider.apiClient.UpdateRecord(ctx, record) 349 | if err != nil { 350 | if retries == r.provider.maxRetries { 351 | return retry.NonRetryableError(err) 352 | } 353 | 354 | return retry.RetryableError(err) 355 | } 356 | 357 | return nil 358 | }) 359 | if err != nil { 360 | resp.Diagnostics.AddError("API Error", fmt.Sprintf("update record: %s", err)) 361 | 362 | return 363 | } 364 | } 365 | 366 | // Save updated data into Terraform state 367 | resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) 368 | } 369 | 370 | func (r *recordResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { 371 | tflog.Trace(ctx, "deleting resource record") 372 | 373 | var state recordResourceModel 374 | 375 | // Read Terraform prior state into the model 376 | resp.Diagnostics.Append(req.State.Get(ctx, &state)...) 377 | 378 | if resp.Diagnostics.HasError() { 379 | return 380 | } 381 | 382 | deleteTimeout, diags := state.Timeouts.Delete(ctx, 5*time.Minute) 383 | resp.Diagnostics.Append(diags...) 384 | 385 | if resp.Diagnostics.HasError() { 386 | return 387 | } 388 | 389 | var ( 390 | err error 391 | retries int64 392 | ) 393 | 394 | err = retry.RetryContext(ctx, deleteTimeout, func() *retry.RetryError { 395 | retries++ 396 | 397 | err = r.provider.apiClient.DeleteRecord(ctx, state.ID.ValueString()) 398 | if err != nil { 399 | if retries == r.provider.maxRetries { 400 | return retry.NonRetryableError(err) 401 | } 402 | 403 | return retry.RetryableError(err) 404 | } 405 | 406 | return nil 407 | }) 408 | if err != nil { 409 | resp.Diagnostics.AddError("API Error", fmt.Sprintf("deleting record %s: %s", state.ID, err)) 410 | 411 | return 412 | } 413 | } 414 | 415 | func (r *recordResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { 416 | resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) 417 | } 418 | -------------------------------------------------------------------------------- /internal/provider/records_data_source.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/germanbrew/terraform-provider-hetznerdns/internal/api" 9 | "github.com/germanbrew/terraform-provider-hetznerdns/internal/utils" 10 | "github.com/hashicorp/terraform-plugin-framework-timeouts/datasource/timeouts" 11 | "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" 12 | "github.com/hashicorp/terraform-plugin-framework/attr" 13 | "github.com/hashicorp/terraform-plugin-framework/datasource" 14 | "github.com/hashicorp/terraform-plugin-framework/datasource/schema" 15 | "github.com/hashicorp/terraform-plugin-framework/schema/validator" 16 | "github.com/hashicorp/terraform-plugin-framework/types" 17 | "github.com/hashicorp/terraform-plugin-log/tflog" 18 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" 19 | ) 20 | 21 | // Ensure provider defined types fully satisfy framework interfaces. 22 | var _ datasource.DataSource = &recordsDataSource{} 23 | 24 | func NewRecordsDataSource() datasource.DataSource { 25 | return &recordsDataSource{} 26 | } 27 | 28 | // recordsDataSource defines the data source implementation. 29 | type recordsDataSource struct { 30 | provider *providerClient 31 | } 32 | 33 | // recordDataSourceModel describes the data source data model. 34 | type recordDataSourceModel struct { 35 | ZoneID types.String `tfsdk:"zone_id"` 36 | ID types.String `tfsdk:"id"` 37 | Type types.String `tfsdk:"type"` 38 | Name types.String `tfsdk:"name"` 39 | Value types.String `tfsdk:"value"` 40 | TTL types.Int64 `tfsdk:"ttl"` 41 | } 42 | 43 | // recordsDataSourceModel describes the data source data model. 44 | type recordsDataSourceModel struct { 45 | ZoneID types.String `tfsdk:"zone_id"` 46 | Records types.List `tfsdk:"records"` 47 | 48 | Timeouts timeouts.Value `tfsdk:"timeouts"` 49 | } 50 | 51 | func (d *recordsDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { 52 | resp.TypeName = req.ProviderTypeName + "_records" 53 | } 54 | 55 | func (d *recordsDataSource) Schema(ctx context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { 56 | resp.Schema = schema.Schema{ 57 | // This description is used by the documentation generator and the language server. 58 | MarkdownDescription: "Provides details about all Records of a Hetzner DNS Zone", 59 | 60 | Attributes: map[string]schema.Attribute{ 61 | "records": schema.ListNestedAttribute{ 62 | MarkdownDescription: "The DNS records of the zone", 63 | Computed: true, 64 | NestedObject: schema.NestedAttributeObject{ 65 | Attributes: map[string]schema.Attribute{ 66 | "zone_id": schema.StringAttribute{ 67 | MarkdownDescription: "ID of the DNS zone", 68 | Computed: true, 69 | }, 70 | "id": schema.StringAttribute{ 71 | MarkdownDescription: "ID of this DNS record", 72 | Computed: true, 73 | }, 74 | "name": schema.StringAttribute{ 75 | MarkdownDescription: "Name of this DNS record", 76 | Computed: true, 77 | }, 78 | "ttl": schema.Int64Attribute{ 79 | MarkdownDescription: "Time to live of this record", 80 | Computed: true, 81 | }, 82 | "type": schema.StringAttribute{ 83 | MarkdownDescription: "Type of this DNS record", 84 | Computed: true, 85 | }, 86 | "value": schema.StringAttribute{ 87 | MarkdownDescription: "Value of this DNS record", 88 | Computed: true, 89 | }, 90 | }, 91 | }, 92 | }, 93 | "zone_id": schema.StringAttribute{ 94 | MarkdownDescription: "ID of the DNS zone to get records from", 95 | Required: true, 96 | Validators: []validator.String{ 97 | stringvalidator.LengthAtLeast(1), 98 | }, 99 | }, 100 | }, 101 | 102 | Blocks: map[string]schema.Block{ 103 | "timeouts": timeouts.BlockWithOpts(ctx, timeouts.Opts{ 104 | ReadDescription: `[Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of 105 | numbers and unit suffixes, such as "30s" or "2h45m". 106 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m`, 107 | }), 108 | }, 109 | } 110 | } 111 | 112 | func (d *recordsDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { 113 | // Prevent panic if the provider has not been configured. 114 | if req.ProviderData == nil { 115 | return 116 | } 117 | 118 | provider, ok := req.ProviderData.(*providerClient) 119 | 120 | if !ok { 121 | resp.Diagnostics.AddError( 122 | "Unexpected Data Source Configure Type", 123 | fmt.Sprintf("Expected *providerClient, got: %T. Please report this issue to the provider developers.", req.ProviderData), 124 | ) 125 | 126 | return 127 | } 128 | 129 | d.provider = provider 130 | } 131 | 132 | func (d *recordsDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { 133 | var data recordsDataSourceModel 134 | 135 | // Read Terraform configuration data into the model 136 | resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) 137 | 138 | if resp.Diagnostics.HasError() { 139 | return 140 | } 141 | 142 | readTimeout, diags := data.Timeouts.Read(ctx, 5*time.Minute) 143 | resp.Diagnostics.Append(diags...) 144 | 145 | if resp.Diagnostics.HasError() { 146 | return 147 | } 148 | 149 | var ( 150 | err error 151 | records *[]api.Record 152 | retries int64 153 | ) 154 | 155 | err = retry.RetryContext(ctx, readTimeout, func() *retry.RetryError { 156 | retries++ 157 | 158 | records, err = d.provider.apiClient.GetRecordsByZoneID(ctx, data.ZoneID.ValueString()) 159 | if err != nil { 160 | if retries == d.provider.maxRetries { 161 | return retry.NonRetryableError(err) 162 | } 163 | 164 | return retry.RetryableError(err) 165 | } 166 | 167 | return nil 168 | }) 169 | if err != nil { 170 | resp.Diagnostics.AddError("API Error", fmt.Sprintf("Unable to get records from zone, got error: %s", err)) 171 | 172 | return 173 | } 174 | 175 | elements := make([]recordDataSourceModel, 0, len(*records)) 176 | 177 | for _, record := range *records { 178 | if record.Type == "TXT" && d.provider.txtFormatter { 179 | value := utils.TXTRecordToPlainValue(record.Value) 180 | if record.Value != value { 181 | tflog.Info(ctx, fmt.Sprintf("split TXT record value %d chunks: %q", len(value), value)) 182 | } 183 | 184 | record.Value = value 185 | } 186 | 187 | elements = append(elements, 188 | recordDataSourceModel{ 189 | ZoneID: types.StringValue(record.ZoneID), 190 | ID: types.StringValue(record.ID), 191 | Type: types.StringValue(record.Type), 192 | Name: types.StringValue(record.Name), 193 | Value: types.StringValue(record.Value), 194 | TTL: types.Int64PointerValue(record.TTL), 195 | }, 196 | ) 197 | } 198 | 199 | data.Records, diags = types.ListValueFrom(ctx, types.ObjectType{ 200 | AttrTypes: map[string]attr.Type{ 201 | "zone_id": types.StringType, 202 | "id": types.StringType, 203 | "type": types.StringType, 204 | "name": types.StringType, 205 | "value": types.StringType, 206 | "ttl": types.Int64Type, 207 | }, 208 | }, elements) 209 | 210 | resp.Diagnostics.Append(diags...) 211 | 212 | if resp.Diagnostics.HasError() { 213 | return 214 | } 215 | 216 | // Save data into Terraform state 217 | resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) 218 | } 219 | -------------------------------------------------------------------------------- /internal/provider/records_data_source_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/hashicorp/terraform-plugin-testing/helper/acctest" 9 | "github.com/hashicorp/terraform-plugin-testing/helper/resource" 10 | ) 11 | 12 | func TestAccRecords_DataSource(t *testing.T) { 13 | aZoneName := acctest.RandString(10) + ".online" 14 | aZoneTTL := 60 15 | 16 | aValue := "192.168.1.1" 17 | aName := acctest.RandString(10) 18 | aType := "A" 19 | annotherValue := acctest.RandString(200) 20 | annotherName := acctest.RandString(10) 21 | annotherType := "TXT" 22 | 23 | resource.Test(t, resource.TestCase{ 24 | PreCheck: func() { testAccPreCheck(t) }, 25 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 26 | Steps: []resource.TestStep{ 27 | // Create and Read testing 28 | { 29 | Config: strings.Join( 30 | []string{ 31 | testAccZoneResourceConfig("test", aZoneName, aZoneTTL), 32 | testAccRecordResourceConfig("record1", aName, aType, aValue), 33 | testAccRecordResourceConfig("record2", annotherName, annotherType, annotherValue), 34 | testAccRecords_DataSourceConfig(), 35 | }, "\n", 36 | ), 37 | Check: resource.ComposeAggregateTestCheckFunc( 38 | resource.TestCheckResourceAttr("data.hetznerdns_records.test", "records.#", "3"), 39 | resource.TestMatchTypeSetElemNestedAttrs("data.hetznerdns_records.test", "records.*", map[string]*regexp.Regexp{ 40 | "zone_id": regexp.MustCompile(`^\S+$`), 41 | "id": regexp.MustCompile(`^\S+$`), 42 | "name": regexp.MustCompile("@"), 43 | "value": regexp.MustCompile(`[a-z.]+ [a-z.]+ [\d ]+`), 44 | "type": regexp.MustCompile("SOA"), 45 | }), 46 | resource.TestMatchTypeSetElemNestedAttrs("data.hetznerdns_records.test", "records.*", map[string]*regexp.Regexp{ 47 | "zone_id": regexp.MustCompile(`^\S+$`), 48 | "id": regexp.MustCompile(`^\S+$`), 49 | "name": regexp.MustCompile(aName), 50 | "value": regexp.MustCompile(aValue), 51 | "type": regexp.MustCompile(aType), 52 | }), 53 | resource.TestMatchTypeSetElemNestedAttrs("data.hetznerdns_records.test", "records.*", map[string]*regexp.Regexp{ 54 | "zone_id": regexp.MustCompile(`^\S+$`), 55 | "id": regexp.MustCompile(`^\S+$`), 56 | "name": regexp.MustCompile(annotherName), 57 | "value": regexp.MustCompile(annotherValue), 58 | "type": regexp.MustCompile(annotherType), 59 | }), 60 | ), 61 | }, 62 | }, 63 | }) 64 | } 65 | 66 | func testAccRecords_DataSourceConfig() string { 67 | return `data "hetznerdns_records" "test" { 68 | zone_id = hetznerdns_zone.test.id 69 | 70 | depends_on = [hetznerdns_record.record1, hetznerdns_record.record2] 71 | }` 72 | } 73 | -------------------------------------------------------------------------------- /internal/provider/zone_data_source.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/germanbrew/terraform-provider-hetznerdns/internal/api" 9 | "github.com/hashicorp/terraform-plugin-framework-timeouts/datasource/timeouts" 10 | "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" 11 | "github.com/hashicorp/terraform-plugin-framework/datasource" 12 | "github.com/hashicorp/terraform-plugin-framework/datasource/schema" 13 | "github.com/hashicorp/terraform-plugin-framework/schema/validator" 14 | "github.com/hashicorp/terraform-plugin-framework/types" 15 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" 16 | ) 17 | 18 | // Ensure provider defined types fully satisfy framework interfaces. 19 | var _ datasource.DataSource = &zoneDataSource{} 20 | 21 | func NewZoneDataSource() datasource.DataSource { 22 | return &zoneDataSource{} 23 | } 24 | 25 | // zoneDataSource defines the data source implementation. 26 | type zoneDataSource struct { 27 | provider *providerClient 28 | } 29 | 30 | // zoneDataSourceModel describes the data source data model. 31 | type zoneDataSourceModel struct { 32 | ID types.String `tfsdk:"id"` 33 | Name types.String `tfsdk:"name"` 34 | TTL types.Int64 `tfsdk:"ttl"` 35 | NS types.List `tfsdk:"ns"` 36 | 37 | Timeouts timeouts.Value `tfsdk:"timeouts"` 38 | } 39 | 40 | func (d *zoneDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { 41 | resp.TypeName = req.ProviderTypeName + "_zone" 42 | } 43 | 44 | func (d *zoneDataSource) Schema(ctx context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { 45 | resp.Schema = schema.Schema{ 46 | // This description is used by the documentation generator and the language server. 47 | MarkdownDescription: "Provides details about a Hetzner DNS Zone", 48 | 49 | Attributes: map[string]schema.Attribute{ 50 | "name": schema.StringAttribute{ 51 | MarkdownDescription: "Name of the DNS zone to get data from", 52 | Required: true, 53 | Validators: []validator.String{ 54 | stringvalidator.LengthAtLeast(1), 55 | }, 56 | }, 57 | "ttl": schema.Int64Attribute{ 58 | MarkdownDescription: "Time to live of this zone", 59 | Computed: true, 60 | }, 61 | "id": schema.StringAttribute{ 62 | MarkdownDescription: "The ID of the DNS zone", 63 | Computed: true, 64 | }, 65 | "ns": schema.ListAttribute{ 66 | MarkdownDescription: "Name Servers of the zone", 67 | Computed: true, 68 | ElementType: types.StringType, 69 | }, 70 | }, 71 | 72 | Blocks: map[string]schema.Block{ 73 | "timeouts": timeouts.BlockWithOpts(ctx, timeouts.Opts{ 74 | ReadDescription: `[Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of 75 | numbers and unit suffixes, such as "30s" or "2h45m". 76 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m`, 77 | }), 78 | }, 79 | } 80 | } 81 | 82 | func (d *zoneDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { 83 | // Prevent panic if the provider has not been configured. 84 | if req.ProviderData == nil { 85 | return 86 | } 87 | 88 | provider, ok := req.ProviderData.(*providerClient) 89 | 90 | if !ok { 91 | resp.Diagnostics.AddError( 92 | "Unexpected Data Source Configure Type", 93 | fmt.Sprintf("Expected *providerClient, got: %T. Please report this issue to the provider developers.", req.ProviderData), 94 | ) 95 | 96 | return 97 | } 98 | 99 | d.provider = provider 100 | } 101 | 102 | func (d *zoneDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { 103 | var data zoneDataSourceModel 104 | 105 | // Read Terraform configuration data into the model 106 | resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) 107 | 108 | if resp.Diagnostics.HasError() { 109 | return 110 | } 111 | 112 | readTimeout, diags := data.Timeouts.Read(ctx, 5*time.Minute) 113 | resp.Diagnostics.Append(diags...) 114 | 115 | if resp.Diagnostics.HasError() { 116 | return 117 | } 118 | 119 | var ( 120 | err error 121 | zone *api.Zone 122 | retries int64 123 | ) 124 | 125 | err = retry.RetryContext(ctx, readTimeout, func() *retry.RetryError { 126 | retries++ 127 | 128 | zone, err = d.provider.apiClient.GetZoneByName(ctx, data.Name.ValueString()) 129 | if err != nil { 130 | if retries == d.provider.maxRetries { 131 | return retry.NonRetryableError(err) 132 | } 133 | 134 | return retry.RetryableError(err) 135 | } 136 | 137 | return nil 138 | }) 139 | if err != nil { 140 | resp.Diagnostics.AddError("API Error", fmt.Sprintf("Unable to get zone, got error: %s", err)) 141 | 142 | return 143 | } 144 | 145 | if zone == nil { 146 | resp.Diagnostics.AddError("API Error", fmt.Sprintf("DNS zone '%s' doesn't exist", data.Name.ValueString())) 147 | 148 | return 149 | } 150 | 151 | ns, diags := types.ListValueFrom(ctx, types.StringType, zone.NS) 152 | 153 | resp.Diagnostics.Append(diags...) 154 | 155 | if resp.Diagnostics.HasError() { 156 | return 157 | } 158 | 159 | data.ID = types.StringValue(zone.ID) 160 | data.Name = types.StringValue(zone.Name) 161 | data.TTL = types.Int64Value(zone.TTL) 162 | data.NS = ns 163 | 164 | // Save data into Terraform state 165 | resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) 166 | } 167 | -------------------------------------------------------------------------------- /internal/provider/zone_data_source_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/hashicorp/terraform-plugin-testing/helper/acctest" 9 | "github.com/hashicorp/terraform-plugin-testing/helper/resource" 10 | ) 11 | 12 | func TestAccZone_DataSource(t *testing.T) { 13 | aZoneName := acctest.RandString(10) + ".online" 14 | aZoneTTL := 60 15 | 16 | resource.Test(t, resource.TestCase{ 17 | PreCheck: func() { testAccPreCheck(t) }, 18 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 19 | Steps: []resource.TestStep{ 20 | // Read testing 21 | { 22 | Config: strings.Join( 23 | []string{ 24 | testAccZoneResourceConfig("test", aZoneName, aZoneTTL), 25 | testAccZoneDataSourceConfig(), 26 | }, "\n", 27 | ), 28 | Check: resource.ComposeAggregateTestCheckFunc( 29 | resource.TestCheckResourceAttrSet("data.hetznerdns_zone.zone1", "id"), 30 | resource.TestCheckResourceAttr("data.hetznerdns_zone.zone1", "name", aZoneName), 31 | resource.TestCheckResourceAttr("data.hetznerdns_zone.zone1", "ttl", strconv.Itoa(aZoneTTL)), 32 | resource.TestCheckResourceAttrSet("data.hetznerdns_zone.zone1", "ns.#"), 33 | ), 34 | }, 35 | }, 36 | }) 37 | } 38 | 39 | func testAccZoneDataSourceConfig() string { 40 | return `data "hetznerdns_zone" "zone1" { 41 | name = hetznerdns_zone.test.name 42 | }` 43 | } 44 | -------------------------------------------------------------------------------- /internal/provider/zone_resource.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "regexp" 8 | "time" 9 | 10 | "github.com/germanbrew/terraform-provider-hetznerdns/internal/api" 11 | "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" 12 | "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" 13 | "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" 14 | "github.com/hashicorp/terraform-plugin-framework/path" 15 | "github.com/hashicorp/terraform-plugin-framework/resource" 16 | "github.com/hashicorp/terraform-plugin-framework/resource/schema" 17 | "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" 18 | "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" 19 | "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" 20 | "github.com/hashicorp/terraform-plugin-framework/schema/validator" 21 | "github.com/hashicorp/terraform-plugin-framework/types" 22 | "github.com/hashicorp/terraform-plugin-log/tflog" 23 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" 24 | ) 25 | 26 | // Ensure provider defined types fully satisfy framework interfaces. 27 | var ( 28 | _ resource.Resource = &zoneResource{} 29 | _ resource.ResourceWithImportState = &zoneResource{} 30 | ) 31 | 32 | func NewZoneResource() resource.Resource { 33 | return &zoneResource{} 34 | } 35 | 36 | // zoneResource defines the resource implementation. 37 | type zoneResource struct { 38 | provider *providerClient 39 | } 40 | 41 | // zoneResourceModel describes the resource data model. 42 | type zoneResourceModel struct { 43 | ID types.String `tfsdk:"id"` 44 | Name types.String `tfsdk:"name"` 45 | TTL types.Int64 `tfsdk:"ttl"` 46 | NS types.List `tfsdk:"ns"` 47 | 48 | Timeouts timeouts.Value `tfsdk:"timeouts"` 49 | } 50 | 51 | func (r *zoneResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { 52 | resp.TypeName = req.ProviderTypeName + "_zone" 53 | } 54 | 55 | func (r *zoneResource) Schema(ctx context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { 56 | resp.Schema = schema.Schema{ 57 | // This description is used by the documentation generator and the language server. 58 | MarkdownDescription: "Provides a Hetzner DNS Zone resource to create, update and delete DNS Zones.", 59 | 60 | Attributes: map[string]schema.Attribute{ 61 | "name": schema.StringAttribute{ 62 | Description: "Name of the DNS zone to create.", 63 | MarkdownDescription: "Name of the DNS zone to create. Must be a valid domain with top level domain. " + 64 | "Meaning `.de` or `.io`. Don't include sub domains on this level. So, no " + 65 | "`sub..io`. The Hetzner API rejects attempts to create a zone with a sub domain name." + 66 | "Use a record to create the sub domain.", 67 | Required: true, 68 | PlanModifiers: []planmodifier.String{ 69 | stringplanmodifier.RequiresReplace(), 70 | }, 71 | Validators: []validator.String{ 72 | stringvalidator.RegexMatches( 73 | regexp.MustCompile(`^[a-z0-9-]+(\.[a-z0-9-]+){1,2}$`), 74 | "Name must be a valid domain with top level domain", 75 | ), 76 | }, 77 | }, 78 | "ttl": schema.Int64Attribute{ 79 | MarkdownDescription: "Time to live of this zone", 80 | Optional: true, 81 | Validators: []validator.Int64{ 82 | int64validator.AtLeast(0), 83 | }, 84 | }, 85 | "id": schema.StringAttribute{ 86 | Computed: true, 87 | MarkdownDescription: "Zone identifier", 88 | PlanModifiers: []planmodifier.String{ 89 | stringplanmodifier.UseStateForUnknown(), 90 | }, 91 | }, 92 | "ns": schema.ListAttribute{ 93 | Computed: true, 94 | MarkdownDescription: "Name Servers of the zone", 95 | ElementType: types.StringType, 96 | PlanModifiers: []planmodifier.List{ 97 | listplanmodifier.UseStateForUnknown(), 98 | }, 99 | }, 100 | }, 101 | 102 | Blocks: map[string]schema.Block{ 103 | "timeouts": timeouts.Block(ctx, timeouts.Opts{ 104 | Create: true, 105 | Read: true, 106 | Update: true, 107 | Delete: true, 108 | 109 | CreateDescription: `[Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of 110 | numbers and unit suffixes, such as "30s" or "2h45m". 111 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m`, 112 | DeleteDescription: `[Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of 113 | numbers and unit suffixes, such as "30s" or "2h45m". 114 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m`, 115 | ReadDescription: `[Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of 116 | numbers and unit suffixes, such as "30s" or "2h45m". 117 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m`, 118 | UpdateDescription: `[Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of 119 | numbers and unit suffixes, such as "30s" or "2h45m". 120 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m`, 121 | }), 122 | }, 123 | } 124 | } 125 | 126 | func (r *zoneResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { 127 | // Prevent panic if the provider has not been configured. 128 | if req.ProviderData == nil { 129 | return 130 | } 131 | 132 | provider, ok := req.ProviderData.(*providerClient) 133 | 134 | if !ok { 135 | resp.Diagnostics.AddError( 136 | "Unexpected Resource Configure Type", 137 | fmt.Sprintf("Expected *providerClient, got: %T. Please report this issue to the provider developers.", req.ProviderData), 138 | ) 139 | 140 | return 141 | } 142 | 143 | r.provider = provider 144 | } 145 | 146 | func (r *zoneResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { 147 | tflog.Trace(ctx, "create resource zone") 148 | 149 | var plan zoneResourceModel 150 | 151 | // Read Terraform plan into the model 152 | resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) 153 | 154 | if resp.Diagnostics.HasError() { 155 | return 156 | } 157 | 158 | createTimeout, diags := plan.Timeouts.Create(ctx, 5*time.Minute) 159 | resp.Diagnostics.Append(diags...) 160 | 161 | if resp.Diagnostics.HasError() { 162 | return 163 | } 164 | 165 | zone, err := r.provider.apiClient.GetZoneByName(ctx, plan.Name.ValueString()) 166 | if err != nil && !errors.Is(err, api.ErrNotFound) { 167 | resp.Diagnostics.AddError("API Error", fmt.Sprintf("error read zone: %s", err)) 168 | 169 | return 170 | } else if zone != nil { 171 | resp.Diagnostics.AddError("Error", fmt.Sprintf("zone %q already exists", plan.Name.ValueString())) 172 | 173 | return 174 | } 175 | 176 | var retries int64 177 | 178 | zoneRequest := api.CreateZoneOpts{ 179 | Name: plan.Name.ValueString(), 180 | TTL: plan.TTL.ValueInt64(), 181 | } 182 | 183 | err = retry.RetryContext(ctx, createTimeout, func() *retry.RetryError { 184 | retries++ 185 | 186 | zone, err = r.provider.apiClient.CreateZone(ctx, zoneRequest) 187 | if err != nil { 188 | if retries == r.provider.maxRetries { 189 | return retry.NonRetryableError(err) 190 | } 191 | 192 | return retry.RetryableError(err) 193 | } 194 | 195 | return nil 196 | }) 197 | if err != nil { 198 | resp.Diagnostics.AddError("API Error", fmt.Sprintf("creating zone: %s", err)) 199 | 200 | return 201 | } 202 | 203 | ns, diags := types.ListValueFrom(ctx, types.StringType, zone.NS) 204 | 205 | resp.Diagnostics.Append(diags...) 206 | 207 | if resp.Diagnostics.HasError() { 208 | return 209 | } 210 | 211 | plan.ID = types.StringValue(zone.ID) 212 | plan.NS = ns 213 | 214 | // Save plan into Terraform state 215 | resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) 216 | } 217 | 218 | func (r *zoneResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { 219 | tflog.Trace(ctx, "read resource zone") 220 | 221 | var state zoneResourceModel 222 | 223 | // Read Terraform prior state into the model 224 | resp.Diagnostics.Append(req.State.Get(ctx, &state)...) 225 | 226 | if resp.Diagnostics.HasError() { 227 | return 228 | } 229 | 230 | readTimeout, diags := state.Timeouts.Read(ctx, 5*time.Minute) 231 | resp.Diagnostics.Append(diags...) 232 | 233 | if resp.Diagnostics.HasError() { 234 | return 235 | } 236 | 237 | var ( 238 | err error 239 | zone *api.Zone 240 | retries int64 241 | ) 242 | 243 | err = retry.RetryContext(ctx, readTimeout, func() *retry.RetryError { 244 | retries++ 245 | 246 | zone, err = r.provider.apiClient.GetZone(ctx, state.ID.ValueString()) 247 | if err != nil { 248 | if retries == r.provider.maxRetries { 249 | return retry.NonRetryableError(err) 250 | } 251 | 252 | return retry.RetryableError(err) 253 | } 254 | 255 | return nil 256 | }) 257 | if err != nil && !errors.Is(err, api.ErrNotFound) { 258 | resp.Diagnostics.AddError("API Error", fmt.Sprintf("read zone: %s", err)) 259 | 260 | return 261 | } 262 | 263 | if zone == nil { 264 | resp.State.RemoveResource(ctx) 265 | 266 | return 267 | } 268 | 269 | ns, diags := types.ListValueFrom(ctx, types.StringType, zone.NS) 270 | 271 | resp.Diagnostics.Append(diags...) 272 | 273 | if resp.Diagnostics.HasError() { 274 | return 275 | } 276 | 277 | state.Name = types.StringValue(zone.Name) 278 | state.TTL = types.Int64Value(zone.TTL) 279 | state.ID = types.StringValue(zone.ID) 280 | state.NS = ns 281 | 282 | // Save updated state into Terraform state 283 | resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) 284 | } 285 | 286 | func (r *zoneResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { 287 | tflog.Trace(ctx, "update resource zone") 288 | 289 | var plan, state zoneResourceModel 290 | 291 | resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) 292 | resp.Diagnostics.Append(req.State.Get(ctx, &state)...) 293 | 294 | if resp.Diagnostics.HasError() { 295 | return 296 | } 297 | 298 | if !plan.TTL.Equal(state.TTL) { 299 | updateTimeout, diags := plan.Timeouts.Update(ctx, 5*time.Minute) 300 | resp.Diagnostics.Append(diags...) 301 | 302 | if resp.Diagnostics.HasError() { 303 | return 304 | } 305 | 306 | var ( 307 | err error 308 | retries int64 309 | ) 310 | 311 | zone := api.Zone{ 312 | ID: state.ID.ValueString(), 313 | Name: plan.Name.ValueString(), 314 | TTL: plan.TTL.ValueInt64(), 315 | } 316 | 317 | err = retry.RetryContext(ctx, updateTimeout, func() *retry.RetryError { 318 | retries++ 319 | 320 | _, err = r.provider.apiClient.UpdateZone(ctx, zone) 321 | if err != nil { 322 | if retries == r.provider.maxRetries { 323 | return retry.NonRetryableError(err) 324 | } 325 | 326 | return retry.RetryableError(err) 327 | } 328 | 329 | return nil 330 | }) 331 | if err != nil { 332 | resp.Diagnostics.AddError("API Error", fmt.Sprintf("update zone: %s", err)) 333 | 334 | return 335 | } 336 | 337 | resp.Diagnostics.Append(diags...) 338 | 339 | if resp.Diagnostics.HasError() { 340 | return 341 | } 342 | } 343 | 344 | // Save updated data into Terraform state 345 | resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) 346 | } 347 | 348 | func (r *zoneResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { 349 | tflog.Trace(ctx, "deleting resource zone") 350 | 351 | var state zoneResourceModel 352 | 353 | // Read Terraform prior state into the model 354 | resp.Diagnostics.Append(req.State.Get(ctx, &state)...) 355 | 356 | if resp.Diagnostics.HasError() { 357 | return 358 | } 359 | 360 | deleteTimeout, diags := state.Timeouts.Delete(ctx, 5*time.Minute) 361 | resp.Diagnostics.Append(diags...) 362 | 363 | if resp.Diagnostics.HasError() { 364 | return 365 | } 366 | 367 | var ( 368 | err error 369 | retries int64 370 | ) 371 | 372 | err = retry.RetryContext(ctx, deleteTimeout, func() *retry.RetryError { 373 | retries++ 374 | 375 | err = r.provider.apiClient.DeleteZone(ctx, state.ID.ValueString()) 376 | if err != nil { 377 | if retries == r.provider.maxRetries { 378 | return retry.NonRetryableError(err) 379 | } 380 | 381 | return retry.RetryableError(err) 382 | } 383 | 384 | return nil 385 | }) 386 | if err != nil { 387 | resp.Diagnostics.AddError("API Error", fmt.Sprintf("deleting zone %s: %s", state.ID, err)) 388 | 389 | return 390 | } 391 | } 392 | 393 | func (r *zoneResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { 394 | resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) 395 | } 396 | -------------------------------------------------------------------------------- /internal/provider/zone_resource_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "regexp" 8 | "strconv" 9 | "testing" 10 | 11 | "github.com/germanbrew/terraform-provider-hetznerdns/internal/api" 12 | "github.com/germanbrew/terraform-provider-hetznerdns/internal/utils" 13 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/logging" 14 | "github.com/hashicorp/terraform-plugin-testing/helper/acctest" 15 | "github.com/hashicorp/terraform-plugin-testing/helper/resource" 16 | ) 17 | 18 | func TestAccZone_Resource(t *testing.T) { 19 | aZoneName := acctest.RandString(10) + ".online" 20 | aZoneTTL := 60 21 | 22 | resource.Test(t, resource.TestCase{ 23 | PreCheck: func() { testAccPreCheck(t) }, 24 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 25 | Steps: []resource.TestStep{ 26 | // Create and Read testing 27 | { 28 | Config: testAccZoneResourceConfig("test", aZoneName, aZoneTTL), 29 | Check: resource.ComposeAggregateTestCheckFunc( 30 | resource.TestCheckResourceAttrSet("hetznerdns_zone.test", "id"), 31 | resource.TestCheckResourceAttr("hetznerdns_zone.test", "name", aZoneName), 32 | resource.TestCheckResourceAttr("hetznerdns_zone.test", "ttl", strconv.Itoa(aZoneTTL)), 33 | resource.TestCheckResourceAttrSet("hetznerdns_zone.test", "ns.#"), 34 | ), 35 | }, 36 | // ImportState testing 37 | { 38 | ResourceName: "hetznerdns_zone.test", 39 | ImportState: true, 40 | ImportStateVerify: true, 41 | }, 42 | // Update and Read testing 43 | { 44 | Config: testAccZoneResourceConfig("test", aZoneName, aZoneTTL*2), 45 | Check: resource.ComposeAggregateTestCheckFunc( 46 | resource.TestCheckResourceAttr("hetznerdns_zone.test", "ttl", strconv.Itoa(aZoneTTL*2)), 47 | resource.TestCheckResourceAttrSet("hetznerdns_zone.test", "ns.#"), 48 | ), 49 | }, 50 | // Delete testing automatically occurs in TestCase 51 | }, 52 | }) 53 | } 54 | 55 | func TestAccZone_TwoDotTLD(t *testing.T) { 56 | aZoneName := acctest.RandString(10) + ".co.za" 57 | aZoneTTL := 60 58 | 59 | resource.Test(t, resource.TestCase{ 60 | PreCheck: func() { testAccPreCheck(t) }, 61 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 62 | Steps: []resource.TestStep{ 63 | // Create and Read testing 64 | { 65 | Config: testAccZoneResourceConfig("test", aZoneName, aZoneTTL), 66 | Check: resource.ComposeAggregateTestCheckFunc( 67 | resource.TestCheckResourceAttrSet("hetznerdns_zone.test", "id"), 68 | resource.TestCheckResourceAttr("hetznerdns_zone.test", "name", aZoneName), 69 | resource.TestCheckResourceAttr("hetznerdns_zone.test", "ttl", strconv.Itoa(aZoneTTL)), 70 | resource.TestCheckResourceAttrSet("hetznerdns_zone.test", "ns.#"), 71 | ), 72 | }, 73 | // ImportState testing 74 | { 75 | ResourceName: "hetznerdns_zone.test", 76 | ImportState: true, 77 | ImportStateVerify: true, 78 | }, 79 | // Update and Read testing 80 | { 81 | Config: testAccZoneResourceConfig("test", aZoneName, aZoneTTL*2), 82 | Check: resource.ComposeAggregateTestCheckFunc( 83 | resource.TestCheckResourceAttr("hetznerdns_zone.test", "ttl", strconv.Itoa(aZoneTTL*2)), 84 | resource.TestCheckResourceAttrSet("hetznerdns_zone.test", "ns.#"), 85 | ), 86 | }, 87 | // Delete testing automatically occurs in TestCase 88 | }, 89 | }) 90 | } 91 | 92 | func TestAccZone_Invalid(t *testing.T) { 93 | aZoneName := "-.de" 94 | aZoneTTL := 60 95 | 96 | resource.Test(t, resource.TestCase{ 97 | PreCheck: func() { testAccPreCheck(t) }, 98 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 99 | Steps: []resource.TestStep{ 100 | // Create and Read testing 101 | { 102 | Config: testAccZoneResourceConfig("test", aZoneName, aZoneTTL), 103 | ExpectError: regexp.MustCompile("422 Unprocessable Content: invalid label"), 104 | }, 105 | // Delete testing automatically occurs in TestCase 106 | }, 107 | }) 108 | } 109 | 110 | func TestAccZone_NoTLD(t *testing.T) { 111 | aZoneName := "de" 112 | aZoneTTL := 60 113 | 114 | resource.Test(t, resource.TestCase{ 115 | PreCheck: func() { testAccPreCheck(t) }, 116 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 117 | Steps: []resource.TestStep{ 118 | // Create and Read testing 119 | { 120 | Config: testAccZoneResourceConfig("test", aZoneName, aZoneTTL), 121 | ExpectError: regexp.MustCompile("Attribute name Name must be a valid domain with top level domain, got: de"), 122 | }, 123 | // Delete testing automatically occurs in TestCase 124 | }, 125 | }) 126 | } 127 | 128 | func TestAccZone_ZoneExists(t *testing.T) { 129 | aZoneName := acctest.RandString(10) + ".online" 130 | aZoneTTL := 60 131 | 132 | resource.Test(t, resource.TestCase{ 133 | PreCheck: func() { testAccPreCheck(t) }, 134 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 135 | Steps: []resource.TestStep{ 136 | // Create and Read testing 137 | { 138 | Config: testAccZoneResourceConfig("test", aZoneName, aZoneTTL), 139 | Check: resource.ComposeAggregateTestCheckFunc( 140 | resource.TestCheckResourceAttrSet("hetznerdns_zone.test", "id"), 141 | resource.TestCheckResourceAttr("hetznerdns_zone.test", "name", aZoneName), 142 | resource.TestCheckResourceAttr("hetznerdns_zone.test", "ttl", strconv.Itoa(aZoneTTL)), 143 | resource.TestCheckResourceAttrSet("hetznerdns_zone.test", "ns.#"), 144 | ), 145 | }, 146 | // ImportState testing 147 | { 148 | ResourceName: "hetznerdns_zone.test", 149 | ImportState: true, 150 | ImportStateVerify: true, 151 | }, 152 | // Create same zone again 153 | { 154 | Config: testAccZoneResourceConfig("test2", aZoneName, aZoneTTL), 155 | ExpectError: regexp.MustCompile(fmt.Sprintf("zone %q already exists", aZoneName)), 156 | }, 157 | // Delete testing automatically occurs in TestCase 158 | }, 159 | }) 160 | } 161 | 162 | func TestAccZone_StaleZone(t *testing.T) { 163 | aZoneName := acctest.RandString(10) + ".online" 164 | aZoneTTL := 60 165 | 166 | resource.Test(t, resource.TestCase{ 167 | PreCheck: func() { testAccPreCheck(t) }, 168 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 169 | Steps: []resource.TestStep{ 170 | // Create and Read testing 171 | { 172 | Config: testAccZoneResourceConfig("test", aZoneName, aZoneTTL), 173 | Check: resource.ComposeAggregateTestCheckFunc( 174 | resource.TestCheckResourceAttrSet("hetznerdns_zone.test", "id"), 175 | resource.TestCheckResourceAttr("hetznerdns_zone.test", "name", aZoneName), 176 | resource.TestCheckResourceAttr("hetznerdns_zone.test", "ttl", strconv.Itoa(aZoneTTL)), 177 | resource.TestCheckResourceAttrSet("hetznerdns_zone.test", "ns.#"), 178 | ), 179 | }, 180 | // ImportState testing 181 | { 182 | ResourceName: "hetznerdns_zone.test", 183 | ImportState: true, 184 | ImportStateVerify: true, 185 | }, 186 | // Remove zone from Hetzner DNS and check if it will be recreated by Terraform 187 | { 188 | PreConfig: func() { 189 | ctx, cancel := context.WithCancel(context.Background()) 190 | defer cancel() 191 | 192 | var ( 193 | data hetznerDNSProviderModel 194 | apiToken string 195 | apiClient *api.Client 196 | err error 197 | ) 198 | 199 | apiToken = utils.ConfigureStringAttribute(data.ApiToken, "HETZNER_DNS_TOKEN", "") 200 | httpClient := logging.NewLoggingHTTPTransport(http.DefaultTransport) 201 | apiClient, err = api.New("https://dns.hetzner.com", apiToken, httpClient) 202 | if err != nil { 203 | t.Fatalf("Error while creating API apiClient: %s", err) 204 | } 205 | zone, err := apiClient.GetZoneByName(ctx, aZoneName) 206 | if err != nil { 207 | t.Fatalf("Error while fetching zone: %s", err) 208 | } 209 | err = apiClient.DeleteZone(ctx, zone.ID) 210 | if err != nil { 211 | t.Fatalf("Error while deleting zone: %s", err) 212 | } 213 | }, 214 | // Check if the zone is recreated 215 | // ExpectNonEmptyPlan: true, 216 | RefreshState: true, 217 | ExpectError: regexp.MustCompile("hetznerdns_zone.test will be created"), 218 | }, 219 | // Delete testing automatically occurs in TestCase 220 | }, 221 | }) 222 | } 223 | 224 | func testAccZoneResourceConfig(resourceName string, name string, ttl int) string { 225 | return fmt.Sprintf(` 226 | resource "hetznerdns_zone" "%[1]s" { 227 | name = %[2]q 228 | ttl = %[3]d 229 | 230 | timeouts { 231 | create = "5s" 232 | delete = "5s" 233 | read = "5s" 234 | update = "5s" 235 | } 236 | }`, resourceName, name, ttl) 237 | } 238 | -------------------------------------------------------------------------------- /internal/utils/ip.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net" 7 | ) 8 | 9 | var ErrInvalidIPAddress = errors.New("invalid IP address") 10 | 11 | // CheckIPAddress checks if the given string is a valid IP address. 12 | func CheckIPAddress(ip string) error { 13 | parsedIP := net.ParseIP(ip) 14 | if parsedIP == nil { 15 | return fmt.Errorf("%w: %s", ErrInvalidIPAddress, ip) 16 | } 17 | 18 | return nil 19 | } 20 | -------------------------------------------------------------------------------- /internal/utils/ip_test.go: -------------------------------------------------------------------------------- 1 | package utils_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/germanbrew/terraform-provider-hetznerdns/internal/utils" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestCheckIPAddress(t *testing.T) { 11 | t.Parallel() 12 | 13 | for _, tc := range []struct { 14 | name string 15 | ip string 16 | isValid bool 17 | }{ 18 | { 19 | name: "valid IPv4", 20 | ip: "9.9.9.9", 21 | isValid: true, 22 | }, 23 | { 24 | name: "invalid IPv4", 25 | ip: "9.9.9.999", 26 | isValid: false, 27 | }, 28 | { 29 | name: "invalid IPv4 with space", 30 | ip: "9.9.9.9 ", 31 | isValid: false, 32 | }, 33 | { 34 | name: "valid IPv6", 35 | ip: "2001:4860:4860::8888", 36 | isValid: true, 37 | }, 38 | { 39 | name: "invalid IPv6", 40 | ip: "2001:4860:4860:::8888", 41 | isValid: false, 42 | }, 43 | { 44 | name: "invalid IPv6 with space", 45 | ip: "2001:4860:4860::8888 ", 46 | isValid: false, 47 | }, 48 | { 49 | name: "invalid IP", 50 | ip: "invalid", 51 | isValid: false, 52 | }, 53 | } { 54 | t.Run(tc.name, func(t *testing.T) { 55 | t.Parallel() 56 | 57 | err := utils.CheckIPAddress(tc.ip) 58 | if tc.isValid { 59 | require.NoError(t, err) 60 | } else { 61 | require.Error(t, err) 62 | } 63 | }) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /internal/utils/provider.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | 8 | "github.com/hashicorp/terraform-plugin-framework/types" 9 | ) 10 | 11 | func ConfigureStringAttribute(attr types.String, envVar, defaultValue string) string { 12 | if !attr.IsNull() { 13 | return attr.ValueString() 14 | } 15 | 16 | if v, ok := os.LookupEnv(envVar); ok { 17 | return v 18 | } 19 | 20 | return defaultValue 21 | } 22 | 23 | func ConfigureInt64Attribute(attr types.Int64, envVar string, defaultValue int64) (int64, error) { 24 | if !attr.IsNull() { 25 | return attr.ValueInt64(), nil 26 | } 27 | 28 | if v, ok := os.LookupEnv(envVar); ok { 29 | vInt64, err := strconv.ParseInt(v, 10, 64) 30 | if err != nil { 31 | return 0, fmt.Errorf("error parsing %s: %w", envVar, err) 32 | } 33 | 34 | return vInt64, nil 35 | } 36 | 37 | return defaultValue, nil 38 | } 39 | 40 | func ConfigureBoolAttribute(attr types.Bool, envVar string, defaultValue bool) (bool, error) { 41 | if !attr.IsNull() { 42 | return attr.ValueBool(), nil 43 | } 44 | 45 | if v, ok := os.LookupEnv(envVar); ok { 46 | vBool, err := strconv.ParseBool(v) 47 | if err != nil { 48 | return false, fmt.Errorf("error parsing %s: %w", envVar, err) 49 | } 50 | 51 | return vBool, nil 52 | } 53 | 54 | return defaultValue, nil 55 | } 56 | -------------------------------------------------------------------------------- /internal/utils/txt.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // PlainToTXTRecordValue Converts a plain string to a TXT record value. 9 | // if the value in a TXT record is longer than 255 bytes, it needs to be split into multiple parts. 10 | // each part needs to be enclosed in double quotes and separated by a space. 11 | // 12 | // https://datatracker.ietf.org/doc/html/rfc4408#section-3.1.3 13 | func PlainToTXTRecordValue(value string) string { 14 | if len(value) < 255 { 15 | return value 16 | } 17 | 18 | if isChunkedTXTRecordValue(value) { 19 | return value 20 | } 21 | 22 | record := strings.Builder{} 23 | 24 | for _, chunk := range chunkSlice(value, 255) { 25 | record.WriteString(fmt.Sprintf("%q ", chunk)) 26 | } 27 | 28 | return record.String() 29 | } 30 | 31 | // TXTRecordToPlainValue converts a TXT record value to a plain string. 32 | // It reverses the operation of PlainToTXTRecordValue. 33 | func TXTRecordToPlainValue(value string) string { 34 | if !isChunkedTXTRecordValue(value) { 35 | return value 36 | } 37 | 38 | // remove the last space 39 | value = strings.TrimSuffix(value, " ") 40 | 41 | // remove the first and last double quote 42 | value = strings.Trim(value, `"`) 43 | 44 | // remove the inner double quotes 45 | value = strings.Join(strings.Split(value, `" "`), "") 46 | 47 | // unescape the escaped double quotes 48 | return strings.ReplaceAll(value, `\"`, `"`) 49 | } 50 | 51 | // isChunkedTXTRecordValue checks if the value is a chunked TXT record value. A chunked TXT record value is a string 52 | // that starts with a double quote and ends with a double quote and optionally a space. 53 | func isChunkedTXTRecordValue(value string) bool { 54 | return strings.HasPrefix(value, `"`) && (strings.HasSuffix(value, `" `) || strings.HasSuffix(value, `"`)) 55 | } 56 | 57 | func chunkSlice(slice string, chunkSize int) []string { 58 | var chunks []string 59 | 60 | for i := 0; i < len(slice); i += chunkSize { 61 | end := i + chunkSize 62 | 63 | // necessary check to avoid slicing beyond 64 | // slice capacity 65 | if end > len(slice) { 66 | end = len(slice) 67 | } 68 | 69 | chunks = append(chunks, slice[i:end]) 70 | } 71 | 72 | return chunks 73 | } 74 | -------------------------------------------------------------------------------- /internal/utils/txt_test.go: -------------------------------------------------------------------------------- 1 | //nolint:lll 2 | package utils_test 3 | 4 | import ( 5 | "strings" 6 | "testing" 7 | 8 | "github.com/germanbrew/terraform-provider-hetznerdns/internal/utils" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestPlainToTXTRecordValue(t *testing.T) { 13 | t.Parallel() 14 | 15 | for _, tc := range []struct { 16 | name string 17 | input string 18 | output string 19 | }{ 20 | { 21 | name: "empty", 22 | input: "", 23 | output: "", 24 | }, 25 | { 26 | name: "small string", 27 | input: "test", 28 | output: "test", 29 | }, 30 | { 31 | name: "small string with quotes", 32 | input: `t"e"s"t`, 33 | output: `t"e"s"t`, 34 | }, 35 | { 36 | name: "small string with spaces", 37 | input: `v=STSv1; id=20230523103000Z`, 38 | output: `v=STSv1; id=20230523103000Z`, 39 | }, 40 | { 41 | name: "large string", 42 | input: strings.Repeat("test", 100), 43 | output: `"testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttes" "ttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest" `, 44 | }, 45 | { 46 | name: "large string with quotes", 47 | input: strings.Repeat(`t"e"s"t`, 100), 48 | output: `"t\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e" "\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"" "tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"t" `, 49 | }, 50 | { 51 | name: "large string with spaces", 52 | input: strings.Repeat(`t e s t`, 100), 53 | output: `"t e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e" " s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s " "tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s t" `, 54 | }, 55 | } { 56 | t.Run(tc.name, func(t *testing.T) { 57 | t.Parallel() 58 | 59 | require.Equal(t, tc.output, utils.PlainToTXTRecordValue(tc.input)) 60 | }) 61 | } 62 | } 63 | 64 | func TestTXTRecordToPlainValue(t *testing.T) { 65 | t.Parallel() 66 | 67 | for _, tc := range []struct { 68 | name string 69 | input string 70 | output string 71 | }{ 72 | { 73 | name: "empty", 74 | input: "", 75 | output: "", 76 | }, 77 | { 78 | name: "small string", 79 | input: "test", 80 | output: "test", 81 | }, 82 | { 83 | name: "small string with quotes", 84 | input: `t"e"s"t`, 85 | output: `t"e"s"t`, 86 | }, 87 | { 88 | name: "small string with spaces", 89 | input: `v=STSv1; id=20230523103000Z`, 90 | output: `v=STSv1; id=20230523103000Z`, 91 | }, 92 | { 93 | name: "large string", 94 | input: `"testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttes" "ttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest" `, 95 | output: strings.Repeat("test", 100), 96 | }, 97 | { 98 | name: "large string with quotes", 99 | input: `"t\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e" "\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"" "tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"tt\"e\"s\"t" `, 100 | output: strings.Repeat(`t"e"s"t`, 100), 101 | }, 102 | { 103 | name: "large string with spaces", 104 | input: `"t e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s t" "t e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s tt e s t" `, 105 | output: strings.Repeat(`t e s t`, 100), 106 | }, 107 | } { 108 | t.Run(tc.name, func(t *testing.T) { 109 | t.Parallel() 110 | 111 | require.Equal(t, tc.output, utils.TXTRecordToPlainValue(tc.input)) 112 | }) 113 | } 114 | } 115 | 116 | func TestPlainToTXTRecordToPlainValue(t *testing.T) { 117 | t.Parallel() 118 | 119 | for _, tc := range []struct { 120 | name string 121 | value string 122 | }{ 123 | { 124 | name: "empty", 125 | value: "", 126 | }, 127 | { 128 | name: "small string", 129 | value: "test", 130 | }, 131 | { 132 | name: "small string with quotes", 133 | value: `t"e"s"t`, 134 | }, 135 | { 136 | name: "small string with spaces", 137 | value: `v=STSv1; id=20230523103000Z`, 138 | }, 139 | { 140 | name: "large string", 141 | value: strings.Repeat("test", 100), 142 | }, 143 | { 144 | name: "large string with quotes", 145 | value: strings.Repeat(`t"e"s"t`, 100), 146 | }, 147 | { 148 | name: "large string with spaces", 149 | value: strings.Repeat("t e s t", 100), 150 | }, 151 | } { 152 | t.Run(tc.name, func(t *testing.T) { 153 | t.Parallel() 154 | 155 | require.Equal(t, tc.value, utils.TXTRecordToPlainValue(utils.TXTRecordToPlainValue(tc.value))) 156 | }) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "log" 7 | 8 | "github.com/germanbrew/terraform-provider-hetznerdns/internal/provider" 9 | "github.com/hashicorp/terraform-plugin-framework/providerserver" 10 | ) 11 | 12 | // Run "go generate" to format example terraform files and generate the docs for the registry/website 13 | 14 | // If you do not have terraform installed, you can remove the formatting command, but its suggested to 15 | // ensure the documentation is formatted properly. 16 | //go:generate terraform fmt -recursive ./examples/ 17 | 18 | // Run the docs generation tool, check its repository for more information on how it works and how docs 19 | // can be customized. 20 | //go:generate go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs generate -provider-name hetznerdns 21 | 22 | // these will be set by the goreleaser configuration 23 | // to appropriate values for the compiled binary. 24 | var version string = "dev" 25 | 26 | // goreleaser can pass other information to the main package, such as the specific commit 27 | // https://goreleaser.com/cookbooks/using-main.version/ 28 | 29 | func main() { 30 | var debug bool 31 | 32 | flag.BoolVar(&debug, "debug", false, "set to true to run the provider with support for debuggers like delve") 33 | flag.Parse() 34 | 35 | opts := providerserver.ServeOpts{ 36 | Address: "registry.terraform.io/germanbrew/hetznerdns", 37 | Debug: debug, 38 | } 39 | 40 | err := providerserver.Serve(context.Background(), provider.New(version), opts) 41 | if err != nil { 42 | log.Fatal(err.Error()) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:best-practices" 5 | ], 6 | "timezone": "Europe/Berlin", 7 | "schedule": ["* 1-6 * * *"], 8 | "dependencyDashboardLabels": ["Kind/Dependency"], 9 | "labels": ["Kind/Dependency"], 10 | "postUpdateOptions": ["gomodTidy", "gomodUpdateImportPaths"], 11 | "packageRules": [ 12 | { 13 | "matchUpdateTypes": ["major"], 14 | "minimumReleaseAge": "2 days" 15 | }, 16 | { 17 | "matchUpdateTypes": ["minor"], 18 | "minimumReleaseAge": "1 days" 19 | }, 20 | { 21 | "matchUpdateTypes": ["patch", "pin"], 22 | "minimumReleaseAge": "1 days", 23 | "automerge": true 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /templates/guides/investigating-rate-limits.md: -------------------------------------------------------------------------------- 1 | --- 2 | subcategory: "" 3 | layout: "hetznerdns" 4 | page_title: "Investigating Rate Limits" 5 | description: |- 6 | A Guide on how to investigate and resolve rate limit issues with the Hetzner DNS API 7 | --- 8 | 9 | # How to investigate and resolve rate limit issues with the Hetzner DNS API 10 | 11 | Hetzner DNS API has a default rate limit of 300 requests per minute. If you're getting a rate limit error, you can investigate and resolve it by following these steps: 12 | 13 | 1. If you're getting a rate limit error like below, try to increase the provider config [`max_retries`](https://registry.terraform.io/providers/germanbrew/hetznerdns/latest/docs#max_retries-1) to a higher value like `10`: 14 | ```bash 15 | Error: API Error 16 | read record: error getting record 3c21...75fb: API returned HTTP 429 Too Many Requests error: rate limit exceeded 17 | ``` 18 | 19 | 2. You can view the ratelimit usage in the terraform logs by running `terraform plan` or `apply` with the `TF_LOG` environment variable set to `DEBUG`. Since the output is probably too long for the terminal, you might want to redirect it to some file like this: 20 | ```bash 21 | TF_LOG=DEBUG TF_LOG_PATH=tf_log_debug.log terraform plan 22 | ``` 23 | The http client will then log the entire http response including all headers. In the headers you will find rate limit details: 24 | 25 | | Header Name | Example Value | 26 | |------------------------------|---------------| 27 | | x-ratelimit-remaining-minute | 296 | 28 | | x-ratelimit-limit-minute | 300 | 29 | | ratelimit-remaining | 296 | 30 | | ratelimit-limit | 300 | 31 | | ratelimit-reset | 50 (seconds) | 32 | 33 | 3. If you need to increase the rate limit, you can contact Hetzner Support to request a higher rate limit for your account. 34 | -------------------------------------------------------------------------------- /templates/guides/migration-from-timohirt-hetznerdns.md: -------------------------------------------------------------------------------- 1 | --- 2 | subcategory: "" 3 | layout: "hetznerdns" 4 | page_title: "Migration from timohirt/hetznerdns" 5 | description: |- 6 | A Guide on how to migrate your Terraform State from timohirt/hetznerdns to germanbrew/hetznerdns 7 | --- 8 | 9 | # How to migrate from timohirt/hetznerdns to germanbrew/hetznerdns 10 | 11 | If you previously used the `timohirt/hetznerdns` provider, you can easily replace the provider in your terraform state by following these steps: 12 | 13 | ~> **NOTE:** It is recommended to backup your Terraform state before migrating by running this command: `terraform state pull > terraform-state-backup.json` 14 | 15 | ## Migration Steps 16 | 17 | 1. In your `terraform` -> `required_providers` config, replace the provider config: 18 | 19 | ```diff 20 | hetznerdns = { 21 | - source = "timohirt/hetznerdns" 22 | + source = "germanbrew/hetznerdns" 23 | - version = "2.2.0" 24 | + version = "3.0.0" # Replace with latest version 25 | } 26 | ``` 27 | 28 | 2. If you have `apitoken` defined inside you provider config, replace it with `api_token`. The environment variable is now called `HETZNER_DNS_TOKEN` instead of `HETZNER_DNS_API_TOKEN`. 29 | Also see our [Docs Overview](https://registry.terraform.io/providers/germanbrew/hetznerdns/latest/docs#schema), as we have more configuration options for you to choose. 30 | 31 | ```diff 32 | provider "hetznerdns" {" 33 | - apitoken = "token" 34 | + api_token = "token" 35 | } 36 | ``` 37 | 38 | 3. Install the new provider and replace it in the state: 39 | 40 | ```sh 41 | terraform init 42 | terraform state replace-provider timohirt/hetznerdns germanbrew/hetznerdns 43 | ``` 44 | 45 | 4. Our provider automatically reformats TXT record values into the correct format ([RFC4408](https://datatracker.ietf.org/doc/html/rfc4408#section-3.1.3)). 46 | This means you don't need to escape the values yourself with `jsonencode()` or other functions to split the records every 255 bytes. 47 | You can disable this feature by specifying `enable_txt_formatter = false` in your provider config or setting the env var `HETZNER_DNS_ENABLE_TXT_FORMATTER=false` 48 | 49 | 5. Test if the migration was successful by running `terraform plan` and checking the output for any errors. 50 | 51 | 6. If you're getting a rate limit error like below, follow the [Investigating Rate Limits](https://registry.terraform.io/providers/germanbrew/hetznerdns/latest/docs/guides/investigating-rate-limits) guide: 52 | ```bash 53 | Error: API Error 54 | read record: error getting record 3c2...: API returned HTTP 429 Too Many Requests error: rate limit exceeded 55 | ``` 56 | -------------------------------------------------------------------------------- /terraform-registry-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "metadata": { 4 | "protocol_versions": ["6.0"] 5 | } 6 | } -------------------------------------------------------------------------------- /tools/tools.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //go:build tools 5 | 6 | package tools 7 | 8 | import ( 9 | // Documentation generation 10 | _ "github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs" 11 | // Linting 12 | _ "github.com/golangci/golangci-lint/v2/cmd/golangci-lint" 13 | ) 14 | --------------------------------------------------------------------------------