├── .gitignore
├── examples
├── resources
│ ├── hetznerdns_zone
│ │ ├── import.sh
│ │ └── resource.tf
│ ├── hetznerdns_primary_server
│ │ ├── import.sh
│ │ └── resource.tf
│ └── hetznerdns_record
│ │ ├── import.sh
│ │ └── resource.tf
├── data-sources
│ ├── hetznerdns_zone
│ │ └── data-source.tf
│ ├── hetznerdns_record
│ │ └── data-source.tf
│ └── hetznerdns_nameservers
│ │ └── data-source.tf
├── functions
│ └── idna
│ │ └── function.tf
└── provider
│ └── provider.tf
├── terraform-registry-manifest.json
├── RELEASING.md
├── internal
├── utils
│ ├── ip.go
│ ├── provider.go
│ ├── ip_test.go
│ ├── txt.go
│ └── txt_test.go
├── provider
│ ├── provider_test.go
│ ├── idna_function_test.go
│ ├── zone_data_source_test.go
│ ├── idna_function.go
│ ├── records_data_source_test.go
│ ├── zone_data_source.go
│ ├── nameserver_data_source_test.go
│ ├── nameserver_data_source.go
│ ├── records_data_source.go
│ ├── provider.go
│ ├── zone_resource_test.go
│ ├── primary_server_resource_test.go
│ ├── primary_server_resource.go
│ ├── zone_resource.go
│ └── record_resource.go
└── api
│ ├── zone_test.go
│ ├── utils.go
│ ├── nameserver.go
│ ├── primary_server.go
│ ├── client.go
│ ├── zone.go
│ ├── record.go
│ └── client_test.go
├── .github
├── workflows
│ ├── cleanup_zones.yaml
│ ├── spelling.yaml
│ ├── release.yaml
│ └── test.yaml
└── release.yml
├── .editorconfig
├── renovate.json
├── Makefile
├── docs
├── functions
│ └── idna.md
├── data-sources
│ ├── zone.md
│ ├── nameservers.md
│ └── records.md
├── guides
│ ├── investigating-rate-limits.md
│ └── migration-from-timohirt-hetznerdns.md
├── index.md
└── resources
│ ├── primary_server.md
│ ├── zone.md
│ └── record.md
├── main.go
├── .golangci.yml
├── templates
└── guides
│ ├── investigating-rate-limits.md
│ └── migration-from-timohirt-hetznerdns.md
├── .goreleaser.yml
├── go.mod
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | .bin/
2 | bin
3 | .vscode
4 | .idea
5 |
--------------------------------------------------------------------------------
/examples/resources/hetznerdns_zone/import.sh:
--------------------------------------------------------------------------------
1 | terraform import hetznerdns_zone.zone1 rMu2waTJPbHr4
--------------------------------------------------------------------------------
/examples/resources/hetznerdns_primary_server/import.sh:
--------------------------------------------------------------------------------
1 | terraform import hetznerdns_zone.zone1 rMu2waTJPbHr4
--------------------------------------------------------------------------------
/examples/data-sources/hetznerdns_zone/data-source.tf:
--------------------------------------------------------------------------------
1 | data "hetznerdns_zone" "zone1" {
2 | name = "zone1.online"
3 | }
--------------------------------------------------------------------------------
/terraform-registry-manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 1,
3 | "metadata": {
4 | "protocol_versions": ["6.0"]
5 | }
6 | }
--------------------------------------------------------------------------------
/examples/functions/idna/function.tf:
--------------------------------------------------------------------------------
1 | resource "hetznerdns_zone" "zone1" {
2 | name = provider::hetznerdns::idna("bücher.example.com")
3 | ttl = 3600
4 | }
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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/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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
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/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 |
--------------------------------------------------------------------------------
/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 | "automerge": true
20 | },
21 | {
22 | "matchUpdateTypes": ["patch", "pin"],
23 | "minimumReleaseAge": "1 days",
24 | "automerge": true
25 | }
26 | ]
27 | }
28 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | GO_BIN?=$(shell pwd)/.bin
2 | BINARY_DIR=bin
3 | BINARY_NAME=terraform-provider-hetznerdns
4 |
5 | .PHONY: build testacc test lint generate docs fmt tools
6 |
7 | build:
8 | mkdir -p $(BINARY_DIR)
9 | go build -o $(BINARY_DIR)/$(BINARY_NAME)
10 |
11 | testacc:
12 | TF_ACC=1 go test -v ./internal/provider -timeout 30m
13 |
14 | test:
15 | go test -v ./... -timeout 30m
16 |
17 | lint fmt:
18 | ${GO_BIN}/golangci-lint run --fix ./...
19 | go tool tfproviderlintx -fix ./...
20 |
21 | generate docs:
22 | go generate ./...
23 |
24 | download:
25 | @echo Download go.mod dependencies
26 | @go mod download
27 |
28 | tools:
29 | mkdir -p ${GO_BIN}
30 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b ${GO_BIN} latest
31 | GOBIN=${GO_BIN} go install tool
32 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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_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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | }
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
21 | with:
22 | # Allow goreleaser to access older tag information.
23 | fetch-depth: 0
24 | - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.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@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.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 |
--------------------------------------------------------------------------------
/.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_v5
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 | wsl_v5:
56 | allow-first-in-block: true
57 | allow-whole-block: false
58 | branch-max-lines: 2
59 | exclusions:
60 | generated: lax
61 | presets:
62 | - comments
63 | - common-false-positives
64 | - legacy
65 | - std-error-handling
66 | rules:
67 | - linters:
68 | - dupword
69 | - funlen
70 | path: _test\.go
71 | paths:
72 | - third_party$
73 | - builtin$
74 | - examples$
75 | issues:
76 | max-issues-per-linter: 0
77 | max-same-issues: 0
78 | formatters:
79 | enable:
80 | - gci
81 | - gofmt
82 | - gofumpt
83 | - goimports
84 | exclusions:
85 | generated: lax
86 | paths:
87 | - third_party$
88 | - builtin$
89 | - examples$
90 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | if resp.Error != nil {
52 | return
53 | }
54 |
55 | domain, err = idna.New().ToASCII(domain)
56 | if err != nil {
57 | resp.Error = function.NewFuncError("failed to convert domain to ASCII: " + err.Error())
58 |
59 | return
60 | }
61 |
62 | resp.Error = function.ConcatFuncErrors(resp.Result.Set(ctx, domain))
63 | }
64 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | # generated by https://github.com/hashicorp/terraform-plugin-docs
3 | page_title: "hetznerdns Provider"
4 | description: |-
5 | This providers helps you automate management of DNS zones and records at Hetzner DNS.
6 | WARNING: This provider is deprecated as of 10 Nov 2025 with the release of the hcloud Console DNS, please migrate to the official Hetzner hcloud provider
7 | ---
8 |
9 | # hetznerdns Provider
10 |
11 | This providers helps you automate management of DNS zones and records at Hetzner DNS.
12 | WARNING: This provider is deprecated as of 10 Nov 2025 with the release of the hcloud Console DNS, please migrate to the official Hetzner hcloud provider
13 |
14 | ## Example Usage
15 |
16 | ```terraform
17 | provider "hetznerdns" {
18 | api_token = ""
19 | }
20 |
21 | data "hetznerdns_zone" "dns_zone" {
22 | name = "example.com"
23 | }
24 |
25 | data "hcloud_server" "web" {
26 | name = "web1"
27 | }
28 |
29 | resource "hetznerdns_record" "web" {
30 | zone_id = data.hetznerdns_zone.dns_zone.id
31 | name = "www"
32 | value = hcloud_server.web.ipv4_address
33 | type = "A"
34 | ttl = 60
35 | }
36 | ```
37 |
38 |
39 | ## Schema
40 |
41 | ### Optional
42 |
43 | - `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.
44 | - `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.
45 | - `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.
46 | - `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.
47 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 | The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example:
68 |
69 | ```shell
70 | terraform import hetznerdns_zone.zone1 rMu2waTJPbHr4
71 | ```
72 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
32 | - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
50 | - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
83 | - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.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 |
--------------------------------------------------------------------------------
/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 | The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example:
91 |
92 | ```shell
93 | terraform import hetznerdns_zone.zone1 rMu2waTJPbHr4
94 | ```
95 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 | The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example:
116 |
117 | ```shell
118 | # A Record can be imported using its `id`. Use the API to get all records of
119 | # a zone and then copy the id.
120 | #
121 | # curl "https://dns.hetzner.com/api/v1/records" \
122 | # -H "Auth-API-Token: $HETZNER_DNS_TOKEN" | jq .
123 | #
124 | # {
125 | # "records": [
126 | # {
127 | # "id": "3d60921a49eb384b6335766a",
128 | # "type": "TXT",
129 | # "name": "google._domainkey",
130 | # "value": "\"anything:with:param\"",
131 | # "zone_id": "rMu2waTJPbHr4",
132 | # "created": "2020-08-18 19:11:02.237 +0000 UTC",
133 | # "modified": "2020-08-28 19:51:41.275 +0000 UTC"
134 | # },
135 | # {
136 | # "id": "ed2416cb6bc8a8055b22222",
137 | # "type": "A",
138 | # "name": "www",
139 | # "value": "1.1.1.1",
140 | # "zone_id": "rMu2waTJPbHr4",
141 | # "created": "2020-08-27 20:55:38.745 +0000 UTC",
142 | # "modified": "2020-08-27 20:55:38.745 +0000 UTC"
143 | # }
144 | # ]
145 | # }
146 |
147 | terraform import hetznerdns_record.dkim_google 3d60921a49eb384b6335766a
148 | ```
149 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/germanbrew/terraform-provider-hetznerdns
2 |
3 | go 1.25.1
4 |
5 | require (
6 | github.com/hashicorp/terraform-plugin-framework v1.16.1
7 | github.com/hashicorp/terraform-plugin-framework-timeouts v0.7.0
8 | github.com/hashicorp/terraform-plugin-framework-validators v0.19.0
9 | github.com/hashicorp/terraform-plugin-go v0.29.0
10 | github.com/hashicorp/terraform-plugin-log v0.9.0
11 | github.com/hashicorp/terraform-plugin-sdk/v2 v2.38.1
12 | github.com/hashicorp/terraform-plugin-testing v1.13.3
13 | github.com/stretchr/testify v1.11.1
14 | golang.org/x/net v0.46.0
15 | )
16 |
17 | require (
18 | github.com/BurntSushi/toml v1.5.0 // indirect
19 | github.com/Kunde21/markdownfmt/v3 v3.1.0 // indirect
20 | github.com/Masterminds/goutils v1.1.1 // indirect
21 | github.com/Masterminds/semver/v3 v3.3.1 // indirect
22 | github.com/Masterminds/sprig/v3 v3.2.3 // indirect
23 | github.com/ProtonMail/go-crypto v1.1.6 // indirect
24 | github.com/agext/levenshtein v1.2.2 // indirect
25 | github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
26 | github.com/armon/go-radix v1.0.0 // indirect
27 | github.com/bflad/gopaniccheck v0.1.0 // indirect
28 | github.com/bflad/tfproviderlint v0.31.0 // indirect
29 | github.com/bgentry/speakeasy v0.1.0 // indirect
30 | github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
31 | github.com/bombsimon/wsl/v5 v5.3.0 // indirect
32 | github.com/catenacyber/perfsprint v0.10.0 // indirect
33 | github.com/cloudflare/circl v1.6.1 // indirect
34 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
35 | github.com/fatih/color v1.18.0 // indirect
36 | github.com/golang/protobuf v1.5.4 // indirect
37 | github.com/google/go-cmp v0.7.0 // indirect
38 | github.com/google/uuid v1.6.0 // indirect
39 | github.com/hashicorp/cli v1.1.7 // indirect
40 | github.com/hashicorp/errwrap v1.1.0 // indirect
41 | github.com/hashicorp/go-checkpoint v0.5.0 // indirect
42 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
43 | github.com/hashicorp/go-cty v1.5.0 // indirect
44 | github.com/hashicorp/go-hclog v1.6.3 // indirect
45 | github.com/hashicorp/go-multierror v1.1.1 // indirect
46 | github.com/hashicorp/go-plugin v1.7.0 // indirect
47 | github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
48 | github.com/hashicorp/go-uuid v1.0.3 // indirect
49 | github.com/hashicorp/go-version v1.7.0 // indirect
50 | github.com/hashicorp/hc-install v0.9.2 // indirect
51 | github.com/hashicorp/hcl/v2 v2.24.0 // indirect
52 | github.com/hashicorp/logutils v1.0.0 // indirect
53 | github.com/hashicorp/terraform-exec v0.24.0 // indirect
54 | github.com/hashicorp/terraform-json v0.27.2 // indirect
55 | github.com/hashicorp/terraform-plugin-docs v0.24.0 // indirect
56 | github.com/hashicorp/terraform-registry-address v0.4.0 // indirect
57 | github.com/hashicorp/terraform-svchost v0.1.1 // indirect
58 | github.com/hashicorp/yamux v0.1.2 // indirect
59 | github.com/huandu/xstrings v1.3.3 // indirect
60 | github.com/imdario/mergo v0.3.15 // indirect
61 | github.com/mattn/go-colorable v0.1.14 // indirect
62 | github.com/mattn/go-isatty v0.0.20 // indirect
63 | github.com/mattn/go-runewidth v0.0.16 // indirect
64 | github.com/mitchellh/copystructure v1.2.0 // indirect
65 | github.com/mitchellh/go-testing-interface v1.14.1 // indirect
66 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect
67 | github.com/mitchellh/mapstructure v1.5.0 // indirect
68 | github.com/mitchellh/reflectwalk v1.0.2 // indirect
69 | github.com/oklog/run v1.1.0 // indirect
70 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
71 | github.com/posener/complete v1.2.3 // indirect
72 | github.com/rivo/uniseg v0.4.7 // indirect
73 | github.com/shopspring/decimal v1.3.1 // indirect
74 | github.com/spf13/cast v1.5.0 // indirect
75 | github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect
76 | github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
77 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
78 | github.com/yuin/goldmark v1.7.7 // indirect
79 | github.com/yuin/goldmark-meta v1.1.0 // indirect
80 | github.com/zclconf/go-cty v1.17.0 // indirect
81 | go.abhg.dev/goldmark/frontmatter v0.2.0 // indirect
82 | golang.org/x/crypto v0.43.0 // indirect
83 | golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
84 | golang.org/x/mod v0.29.0 // indirect
85 | golang.org/x/sync v0.17.0 // indirect
86 | golang.org/x/sys v0.37.0 // indirect
87 | golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 // indirect
88 | golang.org/x/text v0.30.0 // indirect
89 | golang.org/x/tools v0.38.0 // indirect
90 | google.golang.org/appengine v1.6.8 // indirect
91 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
92 | google.golang.org/grpc v1.75.1 // indirect
93 | google.golang.org/protobuf v1.36.9 // indirect
94 | gopkg.in/yaml.v2 v2.4.0 // indirect
95 | gopkg.in/yaml.v3 v3.0.1 // indirect
96 | mvdan.cc/gofumpt v0.9.2 // indirect
97 | )
98 |
99 | tool (
100 | github.com/bflad/tfproviderlint/cmd/tfproviderlintx
101 | github.com/bombsimon/wsl/v5/cmd/wsl
102 | github.com/catenacyber/perfsprint
103 | github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs
104 | golang.org/x/tools/cmd/goimports
105 | mvdan.cc/gofumpt
106 | )
107 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Terraform Provider for Hetzner DNS (Deprecated)
2 |
3 | > [!CAUTION]
4 | > This provider is deprecated as of 10 Nov 2025.
5 | > Please migrate to the official [Hetzner Cloud Terraform Provider](https://registry.terraform.io/providers/hetznercloud/hcloud/latest)
6 | > which now also supports managing DNS records since version [1.54.0](https://github.com/hetznercloud/terraform-provider-hcloud/releases/tag/v1.54.0).
7 |
8 | [](https://registry.terraform.io/providers/germanbrew/hetznerdns/latest)
9 | [](https://search.opentofu.org/provider/germanbrew/hetznerdns/latest)
10 | [](https://github.com/germanbrew/terraform-provider-hetznerdns/releases/latest)
11 | [](https://github.com/germanbrew/terraform-provider-hetznerdns/actions/workflows/test.yaml)
12 |
13 | You can find resources and data sources [documentation](https://registry.terraform.io/providers/germanbrew/hetznerdns/latest/docs) there or [here](docs).
14 |
15 | ## Requirements
16 |
17 | - [Terraform](https://www.terraform.io/downloads.html) > v1.0
18 |
19 | ## Installing and Using this Plugin
20 |
21 | You most likely want to download the provider from [Terraform Registry](https://registry.terraform.io/providers/germanbrew/hetznerdns/latest/docs).
22 | The provider is also published in the [OpenTofu Registry](https://github.com/opentofu/registry/tree/main/providers/g/germanbrew).
23 |
24 | ### Migration Guide
25 |
26 | If you previously used the `timohirt/hetznerdns` provider, you can easily replace the provider in your terraform state
27 | by following our [migration guide in the provider documentation](https://registry.terraform.io/providers/germanbrew/hetznerdns/latest/docs/guides/migration-from-timohirt-hetznerdns).
28 |
29 | ### Using Provider from Terraform Registry (TF >= 1.0)
30 |
31 | This provider is published and available there. If you want to use it, just
32 | add the following to your `terraform.tf`:
33 |
34 | ```terraform
35 | terraform {
36 | required_providers {
37 | hetznerdns = {
38 | source = "germanbrew/hetznerdns"
39 | version = "3.0.0" # Replace with latest version
40 | }
41 | }
42 | required_version = ">= 1.0"
43 | }
44 | ```
45 |
46 | Then run `terraform init` to download the provider.
47 |
48 | ## Authentication
49 |
50 | Once installed, you have three options to provide the required API token that
51 | is used to authenticate at the Hetzner DNS API.
52 |
53 | ### Enter API Token when needed
54 |
55 | You can enter it every time you run `terraform`.
56 |
57 | ### Configure the Provider to take the API Token from a Variable
58 |
59 | Add the following to your `terraform.tf`:
60 |
61 | ```terraform
62 | variable "hetznerdns_token" {}
63 |
64 | provider "hetznerdns" {
65 | api_token = var.hetznerdns_token
66 | }
67 | ```
68 |
69 | Now, assign your API token to `hetznerdns_token` in `terraform.tfvars`:
70 |
71 | ```terraform
72 | hetznerdns_token = "kkd993i3kkmm4m4m4"
73 | ```
74 |
75 | You don't have to enter the API token anymore.
76 |
77 | ### Inject the API Token via the Environment
78 |
79 | Assign the API token to `HETZNER_DNS_TOKEN` env variable.
80 |
81 | ```sh
82 | export HETZNER_DNS_TOKEN=
83 | ```
84 |
85 | The provider uses this token, and you don't have to enter it anymore.
86 |
87 | ## Credits
88 |
89 | This project is a continuation of [timohirt/terraform-provider-hetznerdns](https://github.com/timohirt/terraform-provider-hetznerdns)
90 |
91 | ## Development
92 |
93 | ### Requirements
94 |
95 | - [Go](https://golang.org/) 1.21 (to build the provider plugin)
96 | - [golangci-lint](https://github.com/golangci/golangci-lint) (to lint code)
97 | - [terraform-plugin-docs](https://github.com/hashicorp/terraform-plugin-docs) (to generate registry documentation)
98 |
99 | ### Install and update development tools
100 |
101 | Run the following command
102 |
103 | ```sh
104 | make install-devtools
105 | ```
106 |
107 | ### Makefile Commands
108 |
109 | Check the subcommands in our [Makefile](Makefile) for useful dev tools and scripts.
110 |
111 | ### Testing the provider locally
112 |
113 | To test the provider locally:
114 |
115 | 1. Build the provider binary with `make build`
116 | 2. Create a new file `~/.terraform.rc` and point the provider to the absolute **directory** path of the binary file:
117 | ```hcl
118 | provider_installation {
119 | dev_overrides {
120 | "germanbrew/hetznerdns" = "/path/to/your/terraform-provider-hetznerdns/bin/"
121 | }
122 | direct {}
123 | }
124 | ```
125 | 3. - Set the variable before running terraform commands:
126 |
127 | ```sh
128 | TF_CLI_CONFIG_FILE=~/.terraform.rc terraform plan
129 | ```
130 |
131 | - Or set the env variable `TF_CLI_CONFIG_FILE` and point it to `~/.terraform.rc`: e.g.
132 |
133 | ```sh
134 | export TF_CLI_CONFIG_FILE=~/.terraform.rc`
135 | ```
136 |
137 | 4. Now you can just use terraform normally. A warning will appear, that notifies you that you are using an provider override
138 | ```
139 | Warning: Provider development overrides are in effect
140 | ...
141 | ```
142 | 5. Unset the env variable if you don't want to use the local provider anymore:
143 | ```sh
144 | unset TF_CLI_CONFIG_FILE
145 | ```
146 |
--------------------------------------------------------------------------------
/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/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/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/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/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. \n" +
56 | "WARNING: This provider is deprecated as of 10 Nov 2025 with the release of the hcloud Console DNS, " +
57 | "please migrate to the official Hetzner hcloud provider",
58 | Attributes: map[string]schema.Attribute{
59 | "api_token": schema.StringAttribute{
60 | Description: "The Hetzner DNS API token. You can pass it using the env variable `HETZNER_DNS_TOKEN` as well. " +
61 | "The old env variable `HETZNER_DNS_API_TOKEN` is deprecated and will be removed in a future release.",
62 | Optional: true,
63 | Sensitive: true,
64 | },
65 | "max_retries": schema.Int64Attribute{
66 | Description: "`Default: 1` The maximum number of retries to perform when an API request fails. " +
67 | "You can pass it using the env variable `HETZNER_DNS_MAX_RETRIES` as well.",
68 | Optional: true,
69 | Validators: []validator.Int64{
70 | int64validator.AtLeast(0),
71 | },
72 | },
73 | "enable_txt_formatter": schema.BoolAttribute{
74 | Description: "`Default: true` Toggles the automatic formatter for TXT record values. " +
75 | "Values greater than 255 bytes get split into multiple quoted chunks " +
76 | "([RFC4408](https://datatracker.ietf.org/doc/html/rfc4408#section-3.1.3)). " +
77 | "You can pass it using the env variable `HETZNER_DNS_ENABLE_TXT_FORMATTER` as well.",
78 | Optional: true,
79 | },
80 | "enable_ip_validation": schema.BoolAttribute{
81 | Description: "`Default: true` Toggles the validation of IP addresses in A and AAAA records. " +
82 | "You can pass it using the env variable `HETZNER_DNS_ENABLE_IP_VALIDATION` as well.",
83 | Optional: true,
84 | },
85 | },
86 | }
87 | }
88 |
89 | // Configure configures the provider.
90 | func (p *hetznerDNSProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {
91 | var (
92 | data hetznerDNSProviderModel
93 | apiToken string
94 | err error
95 | )
96 |
97 | client := &providerClient{}
98 |
99 | resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
100 |
101 | if resp.Diagnostics.HasError() {
102 | return
103 | }
104 |
105 | apiToken = utils.ConfigureStringAttribute(data.ApiToken, "HETZNER_DNS_TOKEN", "")
106 | // Still support the deprecated env var for now but show a warning if it's used.
107 | if apiToken == "" {
108 | apiToken = os.Getenv("HETZNER_DNS_API_TOKEN")
109 | if apiToken != "" {
110 | resp.Diagnostics.AddWarning("Deprecated API Token Environment Variable",
111 | "The environment variable `HETZNER_DNS_API_TOKEN` is deprecated and will be removed in a future release. "+
112 | "Please use `HETZNER_DNS_TOKEN` instead.",
113 | )
114 | }
115 | }
116 |
117 | if apiToken == "" {
118 | resp.Diagnostics.AddAttributeError(path.Root("api_token"), "Missing API Token Configuration",
119 | "While configuring the client, the API token was not found in the HETZNER_DNS_TOKEN environment variable or client configuration block "+
120 | "api_token attribute.",
121 | )
122 | }
123 |
124 | client.maxRetries, err = utils.ConfigureInt64Attribute(data.MaxRetries, "HETZNER_DNS_MAX_RETRIES", 1)
125 | if err != nil {
126 | resp.Diagnostics.AddAttributeError(path.Root("max_retries"), "must be an integer", err.Error())
127 | }
128 |
129 | client.txtFormatter, err = utils.ConfigureBoolAttribute(data.EnableTxtFormatter, "HETZNER_DNS_ENABLE_TXT_FORMATTER", true)
130 | if err != nil {
131 | resp.Diagnostics.AddAttributeError(path.Root("enable_txt_formatter"), "must be a boolean", err.Error())
132 | }
133 |
134 | client.ipValidation, err = utils.ConfigureBoolAttribute(data.EnableIPValidation, "HETZNER_DNS_ENABLE_IP_VALIDATION", true)
135 | if err != nil {
136 | resp.Diagnostics.AddAttributeError(path.Root("enable_ip_validation"), "must be a boolean", err.Error())
137 | }
138 |
139 | if resp.Diagnostics.HasError() {
140 | return
141 | }
142 |
143 | httpClient := logging.NewLoggingHTTPTransport(http.DefaultTransport)
144 |
145 | client.apiClient, err = api.New("https://dns.hetzner.com", apiToken, httpClient)
146 | if err != nil {
147 | resp.Diagnostics.AddError("API error while configuring client", fmt.Sprintf("Error while creating API apiClient: %s", err))
148 |
149 | return
150 | }
151 |
152 | client.apiClient.SetUserAgent(fmt.Sprintf("terraform-client-hetznerdns/%s (+https://github.com/germanbrew/terraform-client-hetznerdns) ", p.version))
153 |
154 | if _, err = client.apiClient.GetZones(ctx); err != nil && !errors.Is(err, api.ErrNotFound) {
155 | resp.Diagnostics.AddError("API error", fmt.Sprintf("Error while fetching zones: %s", err))
156 |
157 | return
158 | }
159 |
160 | resp.DataSourceData = client
161 | resp.ResourceData = client
162 | }
163 |
164 | func (p *hetznerDNSProvider) Resources(_ context.Context) []func() resource.Resource {
165 | return []func() resource.Resource{
166 | NewPrimaryServerResource,
167 | NewRecordResource,
168 | NewZoneResource,
169 | }
170 | }
171 |
172 | func (p *hetznerDNSProvider) DataSources(_ context.Context) []func() datasource.DataSource {
173 | return []func() datasource.DataSource{
174 | NewZoneDataSource,
175 | NewRecordsDataSource,
176 | NewNameserversDataSource,
177 | }
178 | }
179 |
180 | func (p *hetznerDNSProvider) Functions(_ context.Context) []func() function.Function {
181 | return []func() function.Function{
182 | NewIdnaFunction,
183 | }
184 | }
185 |
186 | func New(version string) func() provider.Provider {
187 | return func() provider.Provider {
188 | return &hetznerDNSProvider{
189 | version: version,
190 | }
191 | }
192 | }
193 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
202 | apiClient, err = api.New("https://dns.hetzner.com", apiToken, httpClient)
203 | if err != nil {
204 | t.Fatalf("Error while creating API apiClient: %s", err)
205 | }
206 |
207 | zone, err := apiClient.GetZoneByName(ctx, aZoneName)
208 | if err != nil {
209 | t.Fatalf("Error while fetching zone: %s", err)
210 | }
211 |
212 | err = apiClient.DeleteZone(ctx, zone.ID)
213 | if err != nil {
214 | t.Fatalf("Error while deleting zone: %s", err)
215 | }
216 | },
217 | // Check if the zone is recreated
218 | // ExpectNonEmptyPlan: true,
219 | RefreshState: true,
220 | ExpectError: regexp.MustCompile("hetznerdns_zone.test will be created"),
221 | },
222 | // Delete testing automatically occurs in TestCase
223 | },
224 | })
225 | }
226 |
227 | func testAccZoneResourceConfig(resourceName string, name string, ttl int) string {
228 | return fmt.Sprintf(`
229 | resource "hetznerdns_zone" "%[1]s" {
230 | name = %[2]q
231 | ttl = %[3]d
232 |
233 | timeouts {
234 | create = "5s"
235 | delete = "5s"
236 | read = "5s"
237 | update = "5s"
238 | }
239 | }`, resourceName, name, ttl)
240 | }
241 |
--------------------------------------------------------------------------------
/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 |
211 | apiClient, err = api.New("https://dns.hetzner.com", apiToken, httpClient)
212 | if err != nil {
213 | t.Fatalf("Error while creating API apiClient: %s", err)
214 | }
215 |
216 | zone, err := apiClient.GetZoneByName(ctx, aZoneName)
217 | if err != nil {
218 | t.Fatalf("Error while fetching zone: %s", err)
219 | } else if zone == nil {
220 | t.Fatalf("Zone %s not found", aZoneName)
221 | }
222 |
223 | primaryServer, err := apiClient.GetPrimaryServers(ctx, zone.ID)
224 | if err != nil {
225 | t.Fatalf("Error while fetching primary server: %s", err)
226 | } else if primaryServer == nil {
227 | t.Fatalf("Primary server %s not found", zone.ID)
228 | }
229 |
230 | err = apiClient.DeletePrimaryServer(ctx, primaryServer[0].ID)
231 | if err != nil {
232 | t.Fatalf("Error while deleting primary server: %s", err)
233 | }
234 | },
235 | // Check if the record is recreated
236 | // ExpectNonEmptyPlan: true,
237 | RefreshState: true,
238 | ExpectError: regexp.MustCompile("hetznerdns_primary_server.test will be created"),
239 | },
240 | // Delete testing automatically occurs in TestCase
241 | },
242 | })
243 | }
244 |
245 | func testAccPrimaryServerResourceConfigCreate(resourceName, psAddress string, psPort int) string {
246 | return fmt.Sprintf(`
247 | resource "hetznerdns_primary_server" "%s" {
248 | zone_id = hetznerdns_zone.test.id
249 | address = %q
250 | port = %d
251 | }
252 | `, resourceName, psAddress, psPort)
253 | }
254 |
--------------------------------------------------------------------------------
/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 |
52 | client := createTestClient(t, irrelevantConfig)
53 | opts := CreateZoneOpts{Name: "thisisinvalid", TTL: 3600}
54 | _, err := client.CreateZone(context.Background(), opts)
55 |
56 | require.ErrorContains(t, err, "'thisisinvalid' is not a valid domain")
57 | }
58 |
59 | func TestClientUpdateZoneSuccess(t *testing.T) {
60 | t.Parallel()
61 |
62 | zoneWithUpdates := Zone{ID: "12345678", Name: "zone1.online", TTL: 3600, NS: []string{"ns1.zone1.online", "ns2.zone1.online"}}
63 | zoneWithUpdatesJSON := `{"id":"12345678","name":"zone1.online","ns":["ns1.zone1.online","ns2.zone1.online"],"ttl":3600}`
64 |
65 | var requestBodyReader io.Reader
66 |
67 | responseBody := []byte(`{"zone":{"id":"12345678","name":"zone1.online","ns":["ns1.zone1.online","ns2.zone1.online"],"ttl":3600}}`)
68 | config := RequestConfig{responseHTTPStatus: http.StatusOK, requestBodyReader: &requestBodyReader, responseBodyJSON: responseBody}
69 | client := createTestClient(t, config)
70 |
71 | updatedZone, err := client.UpdateZone(context.Background(), zoneWithUpdates)
72 |
73 | require.NoError(t, err)
74 | assert.Equal(t, zoneWithUpdates, *updatedZone)
75 | assert.NotNil(t, requestBodyReader, "The request body should not be nil")
76 | jsonRequestBody, _ := io.ReadAll(requestBodyReader)
77 | assert.JSONEq(t, zoneWithUpdatesJSON, string(jsonRequestBody))
78 | }
79 |
80 | func TestClientGetZone(t *testing.T) {
81 | t.Parallel()
82 |
83 | responseBody := []byte(`{"zone":{"id":"12345678","name":"zone1.online","ttl":3600}}`)
84 | config := RequestConfig{responseHTTPStatus: http.StatusOK, responseBodyJSON: responseBody}
85 | client := createTestClient(t, config)
86 |
87 | zone, err := client.GetZone(context.Background(), "12345678")
88 |
89 | require.NoError(t, err)
90 | assert.Equal(t, Zone{ID: "12345678", Name: "zone1.online", TTL: 3600}, *zone)
91 | }
92 |
93 | func TestClientGetZoneReturnNilIfNotFound(t *testing.T) {
94 | t.Parallel()
95 |
96 | config := RequestConfig{responseHTTPStatus: http.StatusNotFound}
97 | client := createTestClient(t, config)
98 |
99 | zone, err := client.GetZone(context.Background(), "12345678")
100 |
101 | require.ErrorIs(t, err, ErrNotFound)
102 | assert.Nil(t, zone)
103 | }
104 |
105 | func TestClientGetZoneByName(t *testing.T) {
106 | t.Parallel()
107 |
108 | responseBody := []byte(`{"zones":[{"id":"12345678","name":"zone1.online","ttl":3600}]}`)
109 | config := RequestConfig{responseHTTPStatus: http.StatusOK, responseBodyJSON: responseBody}
110 | client := createTestClient(t, config)
111 |
112 | zone, err := client.GetZoneByName(context.Background(), "zone1.online")
113 |
114 | require.NoError(t, err)
115 | assert.Equal(t, Zone{ID: "12345678", Name: "zone1.online", TTL: 3600}, *zone)
116 | }
117 |
118 | func TestClientGetZoneByNameReturnNilIfnotFound(t *testing.T) {
119 | t.Parallel()
120 |
121 | config := RequestConfig{responseHTTPStatus: http.StatusNotFound}
122 | client := createTestClient(t, config)
123 |
124 | zone, err := client.GetZoneByName(context.Background(), "zone1.online")
125 |
126 | require.ErrorIs(t, err, ErrNotFound)
127 | assert.Nil(t, zone)
128 | }
129 |
130 | func TestClientDeleteZone(t *testing.T) {
131 | t.Parallel()
132 |
133 | config := RequestConfig{responseHTTPStatus: http.StatusOK}
134 | client := createTestClient(t, config)
135 |
136 | err := client.DeleteZone(context.Background(), "irrelevant")
137 |
138 | require.NoError(t, err)
139 | }
140 |
141 | func TestClientGetRecord(t *testing.T) {
142 | t.Parallel()
143 |
144 | aTTL := int64(3600)
145 | responseBody := []byte(`{"record":{"zone_id":"wwwlsksjjenm","id":"12345678","name":"zone1.online","ttl":3600,"type":"A","value":"192.168.1.1"}}`)
146 | config := RequestConfig{responseHTTPStatus: http.StatusOK, responseBodyJSON: responseBody}
147 | client := createTestClient(t, config)
148 |
149 | record, err := client.GetRecord(context.Background(), "12345678")
150 |
151 | require.NoError(t, err)
152 | assert.Equal(t, Record{ZoneID: "wwwlsksjjenm", ID: "12345678", Name: "zone1.online", TTL: &aTTL, Type: "A", Value: "192.168.1.1"}, *record)
153 | }
154 |
155 | func TestClientGetRecordWithUndefinedTTL(t *testing.T) {
156 | t.Parallel()
157 |
158 | responseBody := []byte(`{"record":{"zone_id":"wwwlsksjjenm","id":"12345678","name":"zone1.online","type":"A","value":"192.168.1.1"}}`)
159 | config := RequestConfig{responseHTTPStatus: http.StatusOK, responseBodyJSON: responseBody}
160 | client := createTestClient(t, config)
161 |
162 | record, err := client.GetRecord(context.Background(), "12345678")
163 |
164 | require.NoError(t, err)
165 | assert.Equal(t, Record{ZoneID: "wwwlsksjjenm", ID: "12345678", Name: "zone1.online", TTL: nil, Type: "A", Value: "192.168.1.1"}, *record)
166 | }
167 |
168 | func TestClientGetRecordReturnNilIfNotFound(t *testing.T) {
169 | t.Parallel()
170 |
171 | config := RequestConfig{responseHTTPStatus: http.StatusNotFound}
172 | client := createTestClient(t, config)
173 |
174 | record, err := client.GetRecord(context.Background(), "irrelevant")
175 |
176 | require.Error(t, err)
177 | assert.Nil(t, record)
178 | }
179 |
180 | func TestClientCreateRecordSuccess(t *testing.T) {
181 | t.Parallel()
182 |
183 | var requestBodyReader io.Reader
184 |
185 | responseBody := []byte(`{"record":{"zone_id":"wwwlsksjjenm","id":"12345678","name":"zone1.online","ttl":3600,"type":"A","value":"192.168.1.1"}}`)
186 | config := RequestConfig{responseHTTPStatus: http.StatusOK, requestBodyReader: &requestBodyReader, responseBodyJSON: responseBody}
187 | client := createTestClient(t, config)
188 |
189 | aTTL := int64(3600)
190 | opts := CreateRecordOpts{ZoneID: "wwwlsksjjenm", Name: "zone1.online", TTL: &aTTL, Type: "A", Value: "192.168.1.1"}
191 | record, err := client.CreateRecord(context.Background(), opts)
192 |
193 | require.NoError(t, err)
194 | assert.Equal(t, Record{ZoneID: "wwwlsksjjenm", ID: "12345678", Name: "zone1.online", TTL: &aTTL, Type: "A", Value: "192.168.1.1"}, *record)
195 | assert.NotNil(t, requestBodyReader, "The request body should not be nil")
196 | jsonRequestBody, _ := io.ReadAll(requestBodyReader)
197 | assert.JSONEq(t, `{"zone_id":"wwwlsksjjenm","type":"A","name":"zone1.online","value":"192.168.1.1","ttl":3600}`, string(jsonRequestBody))
198 | }
199 |
200 | func TestClientRecordZone(t *testing.T) {
201 | t.Parallel()
202 |
203 | config := RequestConfig{responseHTTPStatus: http.StatusOK}
204 | client := createTestClient(t, config)
205 |
206 | err := client.DeleteRecord(context.Background(), "irrelevant")
207 |
208 | require.NoError(t, err)
209 | }
210 |
211 | func TestClientUpdateRecordSuccess(t *testing.T) {
212 | t.Parallel()
213 |
214 | aTTL := int64(3600)
215 | recordWithUpdates := Record{ZoneID: "wwwlsksjjenm", ID: "12345678", Name: "zone2.online", TTL: &aTTL, Type: "A", Value: "192.168.1.1"}
216 | recordWithUpdatesJSON := `{"zone_id":"wwwlsksjjenm","id":"12345678","type":"A","name":"zone2.online","value":"192.168.1.1","ttl":3600}`
217 |
218 | var requestBodyReader io.Reader
219 |
220 | responseBody := []byte(`{"record":{"zone_id":"wwwlsksjjenm","id":"12345678","type":"A","name":"zone2.online","value":"192.168.1.1","ttl":3600}}`)
221 | config := RequestConfig{responseHTTPStatus: http.StatusOK, requestBodyReader: &requestBodyReader, responseBodyJSON: responseBody}
222 | client := createTestClient(t, config)
223 |
224 | updatedRecord, err := client.UpdateRecord(context.Background(), recordWithUpdates)
225 |
226 | require.NoError(t, err)
227 | assert.Equal(t, recordWithUpdates, *updatedRecord)
228 | assert.NotNil(t, requestBodyReader, "The request body should not be nil")
229 | jsonRequestBody, _ := io.ReadAll(requestBodyReader)
230 | assert.JSONEq(t, recordWithUpdatesJSON, string(jsonRequestBody))
231 | }
232 |
233 | func TestClientHandleUnauthorizedRequest(t *testing.T) {
234 | t.Parallel()
235 |
236 | responseBody := []byte(`{"message":"Invalid API key"}`)
237 | config := RequestConfig{responseHTTPStatus: http.StatusUnauthorized, responseBodyJSON: responseBody}
238 | client := createTestClient(t, config)
239 |
240 | opts := CreateZoneOpts{Name: "mydomain.com", TTL: 3600}
241 | _, err := client.CreateZone(context.Background(), opts)
242 |
243 | require.ErrorContains(t, err, "'Invalid API key'", "Error message didn't contain error message from API.")
244 | }
245 |
246 | type RequestConfig struct {
247 | responseHTTPStatus int
248 | responseBodyJSON []byte
249 | requestBodyReader *io.Reader
250 | }
251 |
252 | func createTestClient(t testing.TB, config RequestConfig) *Client {
253 | t.Helper()
254 |
255 | client, err := New("http://localhost/", "irrelevant", TestClient{config: config})
256 | require.NoError(t, err)
257 |
258 | return client
259 | }
260 |
261 | type TestClient struct {
262 | config RequestConfig
263 | }
264 |
265 | // See https://golang.org/pkg/net/http/#RoundTripper
266 | func (f TestClient) RoundTrip(req *http.Request) (*http.Response, error) {
267 | if req.Body != nil && f.config.requestBodyReader != nil {
268 | *f.config.requestBodyReader = req.Body
269 | }
270 |
271 | var jsonBody io.ReadCloser = nil
272 | if f.config.responseBodyJSON != nil {
273 | jsonBody = io.NopCloser(bytes.NewReader(f.config.responseBodyJSON))
274 | }
275 |
276 | resp := http.Response{StatusCode: f.config.responseHTTPStatus, Body: jsonBody}
277 |
278 | return &resp, nil
279 | }
280 |
--------------------------------------------------------------------------------
/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/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/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 | PlanModifiers: []planmodifier.String{
77 | stringplanmodifier.RequiresReplaceIfConfigured(),
78 | },
79 | Validators: []validator.String{
80 | stringvalidator.LengthAtLeast(1),
81 | },
82 | },
83 | "name": schema.StringAttribute{
84 | MarkdownDescription: "Name of the DNS record to create",
85 | Required: true,
86 | Validators: []validator.String{
87 | stringvalidator.LengthAtLeast(1),
88 | },
89 | },
90 | "value": schema.StringAttribute{
91 | Description: "The value of the record (e.g. 192.168.1.1)",
92 | MarkdownDescription: "The value of the record (e.g. `192.168.1.1`)",
93 | Required: true,
94 | Validators: []validator.String{
95 | stringvalidator.LengthAtLeast(1),
96 | },
97 | },
98 | "ttl": schema.Int64Attribute{
99 | MarkdownDescription: "Time to live of this record",
100 | Optional: true,
101 | Validators: []validator.Int64{
102 | int64validator.AtLeast(0),
103 | },
104 | },
105 | "id": schema.StringAttribute{
106 | Computed: true,
107 | MarkdownDescription: "Zone identifier",
108 | PlanModifiers: []planmodifier.String{
109 | stringplanmodifier.UseStateForUnknown(),
110 | },
111 | },
112 | },
113 |
114 | Blocks: map[string]schema.Block{
115 | "timeouts": timeouts.Block(ctx, timeouts.Opts{
116 | Create: true,
117 | Read: true,
118 | Update: true,
119 | Delete: true,
120 |
121 | CreateDescription: `[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 | DeleteDescription: `[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 | ReadDescription: `[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 | UpdateDescription: `[Operation Timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) consisting of
131 | numbers and unit suffixes, such as "30s" or "2h45m".
132 | Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Default: 5m`,
133 | }),
134 | },
135 | }
136 | }
137 |
138 | func (r *recordResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
139 | // Prevent panic if the provider has not been configured.
140 | if req.ProviderData == nil {
141 | return
142 | }
143 |
144 | provider, ok := req.ProviderData.(*providerClient)
145 |
146 | if !ok {
147 | resp.Diagnostics.AddError(
148 | "Unexpected Resource Configure Type",
149 | fmt.Sprintf("Expected *providerClient, got: %T. Please report this issue to the provider developers.", req.ProviderData),
150 | )
151 |
152 | return
153 | }
154 |
155 | r.provider = provider
156 | }
157 |
158 | func (r *recordResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
159 | tflog.Trace(ctx, "create resource record")
160 |
161 | var plan recordResourceModel
162 |
163 | // Read Terraform plan into the model
164 | resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
165 |
166 | if resp.Diagnostics.HasError() {
167 | return
168 | }
169 |
170 | createTimeout, diags := plan.Timeouts.Create(ctx, 5*time.Minute)
171 | resp.Diagnostics.Append(diags...)
172 |
173 | if resp.Diagnostics.HasError() {
174 | return
175 | }
176 |
177 | value := plan.Value.ValueString()
178 | if plan.Type.ValueString() == "TXT" && r.provider.txtFormatter {
179 | value = utils.PlainToTXTRecordValue(value)
180 | if plan.Value.ValueString() != value {
181 | tflog.Debug(ctx, fmt.Sprintf("split TXT record value %d chunks: %q", len(value), value))
182 | }
183 | }
184 |
185 | if (plan.Type.ValueString() == "A" || plan.Type.ValueString() == "AAAA") && r.provider.ipValidation {
186 | err := utils.CheckIPAddress(value)
187 | if err != nil {
188 | resp.Diagnostics.AddError("Invalid IP address", err.Error())
189 |
190 | return
191 | }
192 | }
193 |
194 | var (
195 | err error
196 | record *api.Record
197 | retries int64
198 | )
199 |
200 | recordRequest := api.CreateRecordOpts{
201 | ZoneID: plan.ZoneID.ValueString(),
202 | Name: plan.Name.ValueString(),
203 | Type: plan.Type.ValueString(),
204 | Value: value,
205 | TTL: plan.TTL.ValueInt64Pointer(),
206 | }
207 |
208 | err = retry.RetryContext(ctx, createTimeout, func() *retry.RetryError {
209 | retries++
210 |
211 | record, err = r.provider.apiClient.CreateRecord(ctx, recordRequest)
212 | if err != nil {
213 | if retries == r.provider.maxRetries {
214 | return retry.NonRetryableError(err)
215 | }
216 |
217 | return retry.RetryableError(err)
218 | }
219 |
220 | return nil
221 | })
222 | if err != nil {
223 | resp.Diagnostics.AddError("API Error", fmt.Sprintf("creating record: %s", err))
224 |
225 | return
226 | }
227 |
228 | plan.ID = types.StringValue(record.ID)
229 |
230 | // Save plan into Terraform state
231 | resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
232 | }
233 |
234 | func (r *recordResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
235 | tflog.Trace(ctx, "read resource record")
236 |
237 | var state recordResourceModel
238 |
239 | // Read Terraform prior state into the model
240 | resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
241 |
242 | if resp.Diagnostics.HasError() {
243 | return
244 | }
245 |
246 | readTimeout, diags := state.Timeouts.Read(ctx, 5*time.Minute)
247 | resp.Diagnostics.Append(diags...)
248 |
249 | if resp.Diagnostics.HasError() {
250 | return
251 | }
252 |
253 | var (
254 | err error
255 | record *api.Record
256 | retries int64
257 | )
258 |
259 | err = retry.RetryContext(ctx, readTimeout, func() *retry.RetryError {
260 | retries++
261 |
262 | record, err = r.provider.apiClient.GetRecord(ctx, state.ID.ValueString())
263 | if err != nil {
264 | if retries == r.provider.maxRetries {
265 | return retry.NonRetryableError(err)
266 | }
267 |
268 | return retry.RetryableError(err)
269 | }
270 |
271 | return nil
272 | })
273 | if err != nil && !errors.Is(err, api.ErrNotFound) {
274 | resp.Diagnostics.AddError("API Error", fmt.Sprintf("read record: %s", err))
275 |
276 | return
277 | }
278 |
279 | if record == nil {
280 | resp.State.RemoveResource(ctx)
281 |
282 | return
283 | }
284 |
285 | if record.Type == "TXT" && r.provider.txtFormatter {
286 | record.Value = utils.TXTRecordToPlainValue(record.Value)
287 | }
288 |
289 | state.Name = types.StringValue(record.Name)
290 | state.TTL = types.Int64PointerValue(record.TTL)
291 | state.ZoneID = types.StringValue(record.ZoneID)
292 | state.Type = types.StringValue(record.Type)
293 | state.Value = types.StringValue(record.Value)
294 | state.ID = types.StringValue(record.ID)
295 |
296 | // Save updated state into Terraform state
297 | resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
298 | }
299 |
300 | func (r *recordResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
301 | tflog.Trace(ctx, "updating resource record")
302 |
303 | var plan, state recordResourceModel
304 |
305 | resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
306 | resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
307 |
308 | if resp.Diagnostics.HasError() {
309 | return
310 | }
311 |
312 | value := plan.Value.ValueString()
313 | if plan.Type.ValueString() == "TXT" && r.provider.txtFormatter {
314 | value = utils.PlainToTXTRecordValue(value)
315 | }
316 |
317 | if (plan.Type.ValueString() == "A" || plan.Type.ValueString() == "AAAA") && r.provider.ipValidation {
318 | err := utils.CheckIPAddress(value)
319 | if err != nil {
320 | resp.Diagnostics.AddError("Invalid IP address", err.Error())
321 |
322 | return
323 | }
324 | }
325 |
326 | if !plan.Name.Equal(state.Name) || !plan.TTL.Equal(state.TTL) || !plan.Type.Equal(state.Type) || !plan.Value.Equal(state.Value) {
327 | updateTimeout, diags := plan.Timeouts.Update(ctx, 5*time.Minute)
328 | resp.Diagnostics.Append(diags...)
329 |
330 | if resp.Diagnostics.HasError() {
331 | return
332 | }
333 |
334 | var (
335 | err error
336 | retries int64
337 | )
338 |
339 | record := api.Record{
340 | ID: state.ID.ValueString(),
341 | Name: plan.Name.ValueString(),
342 | Type: plan.Type.ValueString(),
343 | Value: value,
344 | TTL: plan.TTL.ValueInt64Pointer(),
345 | ZoneID: plan.ZoneID.ValueString(),
346 | }
347 |
348 | err = retry.RetryContext(ctx, updateTimeout, func() *retry.RetryError {
349 | retries++
350 |
351 | _, err = r.provider.apiClient.UpdateRecord(ctx, record)
352 | if err != nil {
353 | if retries == r.provider.maxRetries {
354 | return retry.NonRetryableError(err)
355 | }
356 |
357 | return retry.RetryableError(err)
358 | }
359 |
360 | return nil
361 | })
362 | if err != nil {
363 | resp.Diagnostics.AddError("API Error", fmt.Sprintf("update record: %s", err))
364 |
365 | return
366 | }
367 | }
368 |
369 | // Save updated data into Terraform state
370 | resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
371 | }
372 |
373 | func (r *recordResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
374 | tflog.Trace(ctx, "deleting resource record")
375 |
376 | var state recordResourceModel
377 |
378 | // Read Terraform prior state into the model
379 | resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
380 |
381 | if resp.Diagnostics.HasError() {
382 | return
383 | }
384 |
385 | deleteTimeout, diags := state.Timeouts.Delete(ctx, 5*time.Minute)
386 | resp.Diagnostics.Append(diags...)
387 |
388 | if resp.Diagnostics.HasError() {
389 | return
390 | }
391 |
392 | var (
393 | err error
394 | retries int64
395 | )
396 |
397 | err = retry.RetryContext(ctx, deleteTimeout, func() *retry.RetryError {
398 | retries++
399 |
400 | err = r.provider.apiClient.DeleteRecord(ctx, state.ID.ValueString())
401 | if err != nil {
402 | if retries == r.provider.maxRetries {
403 | return retry.NonRetryableError(err)
404 | }
405 |
406 | return retry.RetryableError(err)
407 | }
408 |
409 | return nil
410 | })
411 | if err != nil {
412 | resp.Diagnostics.AddError("API Error", fmt.Sprintf("deleting record %s: %s", state.ID, err))
413 |
414 | return
415 | }
416 | }
417 |
418 | func (r *recordResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
419 | resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
420 | }
421 |
--------------------------------------------------------------------------------