Skip to content

Commit e92d7d5

Browse files
authored
Merge pull request #17 from platform9/feat/dns-designate
feat(dns): Designate DNS family (pcd_dns_zone, pcd_dns_recordset) — Phase 3
2 parents 1bf607f + 5515a14 commit e92d7d5

15 files changed

Lines changed: 1035 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ All notable changes to this project are documented here. The format is based on
6060
root load balancer and waits for its `provisioning_status` to return to `ACTIVE` before
6161
and after mutating (Octavia serializes changes per load balancer). Code-complete; see
6262
DECISIONS.md for live-validation status.
63+
- DNS (Designate v2) — Phase 3: `pcd_dns_zone` and `pcd_dns_recordset` resources plus a
64+
`pcd_dns_zone` data source. Zone and recordset create/update/delete are asynchronous,
65+
so applies wait for the object to reach `ACTIVE` (and to disappear after delete).
6366

6467
- Registry documentation generation wired via `tfplugindocs` (`make generate`) — renders
6568
`docs/` for every resource and data source plus the provider index from schema
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
data "pcd_dns_zone" "example" {
2+
name = "example.com."
3+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
terraform import pcd_dns_recordset.example <zone_id>/<recordset_id>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
resource "pcd_dns_zone" "example" {
2+
name = "example.com."
3+
email = "admin@example.com"
4+
}
5+
6+
resource "pcd_dns_recordset" "example" {
7+
zone_id = pcd_dns_zone.example.id
8+
name = "www.example.com."
9+
type = "A"
10+
ttl = 300
11+
records = ["10.0.0.1", "10.0.0.2"]
12+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
terraform import pcd_dns_zone.example <id>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
resource "pcd_dns_zone" "example" {
2+
name = "example.com."
3+
email = "admin@example.com"
4+
ttl = 3600
5+
description = "Managed by Terraform"
6+
}

internal/clients/config.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,17 @@ func (c *Config) LoadBalancerV2Client() (*gophercloud.ServiceClient, error) {
246246
return client, nil
247247
}
248248

249+
// DNSV2Client returns a Designate v2 service client, honoring an
250+
// endpoint_overrides entry for the "dns" service type if present.
251+
func (c *Config) DNSV2Client() (*gophercloud.ServiceClient, error) {
252+
client, err := openstack.NewDNSV2(c.Provider, c.endpointOpts())
253+
if err != nil {
254+
return nil, fmt.Errorf("pcd: creating dns v2 client: %w", err)
255+
}
256+
c.applyOverride(client, "dns")
257+
return client, nil
258+
}
259+
249260
// applyOverride points a service client at an operator-supplied endpoint when
250261
// endpoint_overrides names its service type.
251262
func (c *Config) applyOverride(client *gophercloud.ServiceClient, serviceType string) {

internal/provider/provider.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
"github.com/platform9/terraform-provider-pcd/internal/services/blockstorage"
1414
"github.com/platform9/terraform-provider-pcd/internal/services/compute"
15+
"github.com/platform9/terraform-provider-pcd/internal/services/dns"
1516
"github.com/platform9/terraform-provider-pcd/internal/services/identity"
1617
"github.com/platform9/terraform-provider-pcd/internal/services/images"
1718
"github.com/platform9/terraform-provider-pcd/internal/services/loadbalancer"
@@ -74,6 +75,8 @@ func (p *pcdProvider) Resources(_ context.Context) []func() resource.Resource {
7475
loadbalancer.NewMonitorResource,
7576
loadbalancer.NewL7PolicyResource,
7677
loadbalancer.NewL7RuleResource,
78+
dns.NewZoneResource,
79+
dns.NewRecordSetResource,
7780
}
7881
}
7982

@@ -99,5 +102,6 @@ func (p *pcdProvider) DataSources(_ context.Context) []func() datasource.DataSou
99102
blockstorage.NewVolumeDataSource,
100103
blockstorage.NewSnapshotDataSource,
101104
loadbalancer.NewLoadBalancerDataSource,
105+
dns.NewZoneDataSource,
102106
}
103107
}

internal/services/dns/dns.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// Copyright (c) Platform9 Systems, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
// Package dns implements the pcd_dns_* resources and data sources (Designate v2),
5+
// ported from terraform-provider-openstack v3.4.0.
6+
//
7+
// Designate zone and recordset operations are asynchronous: create/update return
8+
// the object in a PENDING status that settles to ACTIVE, and delete leaves the
9+
// object PENDING_DELETE until it is gone. Resources wait for the object to reach
10+
// ACTIVE after create/update and to 404 after delete.
11+
package dns
12+
13+
import (
14+
"context"
15+
"fmt"
16+
"net/http"
17+
"strings"
18+
"time"
19+
20+
"github.com/gophercloud/gophercloud/v2"
21+
"github.com/gophercloud/gophercloud/v2/openstack/dns/v2/recordsets"
22+
"github.com/gophercloud/gophercloud/v2/openstack/dns/v2/zones"
23+
"github.com/hashicorp/terraform-plugin-framework/diag"
24+
"github.com/hashicorp/terraform-plugin-framework/types"
25+
26+
"github.com/platform9/terraform-provider-pcd/internal/clients"
27+
)
28+
29+
// listToStrings converts a list attribute to a Go slice (nil for null/unknown).
30+
func listToStrings(ctx context.Context, l types.List, diags *diag.Diagnostics) []string {
31+
if l.IsNull() || l.IsUnknown() {
32+
return nil
33+
}
34+
var out []string
35+
diags.Append(l.ElementsAs(ctx, &out, false)...)
36+
return out
37+
}
38+
39+
// mapToStrings converts a map attribute to a Go map (nil for null/unknown).
40+
func mapToStrings(ctx context.Context, m types.Map, diags *diag.Diagnostics) map[string]string {
41+
if m.IsNull() || m.IsUnknown() {
42+
return nil
43+
}
44+
out := map[string]string{}
45+
diags.Append(m.ElementsAs(ctx, &out, false)...)
46+
return out
47+
}
48+
49+
// setToStrings converts a set attribute to a Go slice (nil for null/unknown).
50+
func setToStrings(ctx context.Context, s types.Set, diags *diag.Diagnostics) []string {
51+
if s.IsNull() || s.IsUnknown() {
52+
return nil
53+
}
54+
var out []string
55+
diags.Append(s.ElementsAs(ctx, &out, false)...)
56+
return out
57+
}
58+
59+
// splitZoneChildID parses a composite "<zone_id>/<recordset_id>" import ID.
60+
func splitZoneChildID(id string) (zoneID, child string, err error) {
61+
parts := strings.SplitN(id, "/", 2)
62+
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
63+
return "", "", fmt.Errorf("expected import ID in the form <zone_id>/<recordset_id>, got %q", id)
64+
}
65+
return parts[0], parts[1], nil
66+
}
67+
68+
// Designate status values (plain strings in the API; no exported constants).
69+
const (
70+
dnsActive = "ACTIVE"
71+
dnsError = "ERROR"
72+
)
73+
74+
// defaultDNSTimeout bounds each wait for a zone or recordset to settle.
75+
const defaultDNSTimeout = 10 * time.Minute
76+
77+
// configureClient extracts the shared *clients.Config from ProviderData.
78+
func configureClient(providerData any, diags *diag.Diagnostics) *clients.Config {
79+
if providerData == nil {
80+
return nil
81+
}
82+
config, ok := providerData.(*clients.Config)
83+
if !ok {
84+
diags.AddError(
85+
"Unexpected provider data type",
86+
fmt.Sprintf("Expected *clients.Config, got %T. This is a bug in the provider.", providerData),
87+
)
88+
return nil
89+
}
90+
return config
91+
}
92+
93+
// waitForZoneActive blocks until the zone reaches ACTIVE, failing on ERROR/timeout.
94+
func waitForZoneActive(ctx context.Context, client *gophercloud.ServiceClient, zoneID string, timeout time.Duration) error {
95+
ctx, cancel := context.WithTimeout(ctx, timeout)
96+
defer cancel()
97+
err := gophercloud.WaitFor(ctx, func(ctx context.Context) (bool, error) {
98+
z, err := zones.Get(ctx, client, zoneID).Extract()
99+
if err != nil {
100+
return false, err
101+
}
102+
switch z.Status {
103+
case dnsActive:
104+
return true, nil
105+
case dnsError:
106+
return false, fmt.Errorf("zone %s entered ERROR status", zoneID)
107+
default:
108+
return false, nil
109+
}
110+
})
111+
if err != nil {
112+
return fmt.Errorf("waiting for zone %s to become active: %w", zoneID, err)
113+
}
114+
return nil
115+
}
116+
117+
// waitForZoneDeleted blocks until the zone is gone (404).
118+
func waitForZoneDeleted(ctx context.Context, client *gophercloud.ServiceClient, zoneID string, timeout time.Duration) error {
119+
ctx, cancel := context.WithTimeout(ctx, timeout)
120+
defer cancel()
121+
err := gophercloud.WaitFor(ctx, func(ctx context.Context) (bool, error) {
122+
z, err := zones.Get(ctx, client, zoneID).Extract()
123+
if err != nil {
124+
if gophercloud.ResponseCodeIs(err, http.StatusNotFound) {
125+
return true, nil
126+
}
127+
return false, err
128+
}
129+
if z.Status == dnsError {
130+
return false, fmt.Errorf("zone %s entered ERROR status during delete", zoneID)
131+
}
132+
return false, nil
133+
})
134+
if err != nil {
135+
return fmt.Errorf("waiting for zone %s to delete: %w", zoneID, err)
136+
}
137+
return nil
138+
}
139+
140+
// waitForRecordSetActive blocks until the recordset reaches ACTIVE.
141+
func waitForRecordSetActive(ctx context.Context, client *gophercloud.ServiceClient, zoneID, rrID string, timeout time.Duration) error {
142+
ctx, cancel := context.WithTimeout(ctx, timeout)
143+
defer cancel()
144+
err := gophercloud.WaitFor(ctx, func(ctx context.Context) (bool, error) {
145+
rr, err := recordsets.Get(ctx, client, zoneID, rrID).Extract()
146+
if err != nil {
147+
return false, err
148+
}
149+
switch rr.Status {
150+
case dnsActive:
151+
return true, nil
152+
case dnsError:
153+
return false, fmt.Errorf("recordset %s entered ERROR status", rrID)
154+
default:
155+
return false, nil
156+
}
157+
})
158+
if err != nil {
159+
return fmt.Errorf("waiting for recordset %s to become active: %w", rrID, err)
160+
}
161+
return nil
162+
}
163+
164+
// waitForRecordSetDeleted blocks until the recordset is gone (404).
165+
func waitForRecordSetDeleted(ctx context.Context, client *gophercloud.ServiceClient, zoneID, rrID string, timeout time.Duration) error {
166+
ctx, cancel := context.WithTimeout(ctx, timeout)
167+
defer cancel()
168+
err := gophercloud.WaitFor(ctx, func(ctx context.Context) (bool, error) {
169+
_, err := recordsets.Get(ctx, client, zoneID, rrID).Extract()
170+
if err != nil {
171+
if gophercloud.ResponseCodeIs(err, http.StatusNotFound) {
172+
return true, nil
173+
}
174+
return false, err
175+
}
176+
return false, nil
177+
})
178+
if err != nil {
179+
return fmt.Errorf("waiting for recordset %s to delete: %w", rrID, err)
180+
}
181+
return nil
182+
}

internal/services/dns/dns_test.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// Copyright (c) Platform9 Systems, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package dns_test
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"net/http"
10+
"testing"
11+
12+
"github.com/gophercloud/gophercloud/v2"
13+
"github.com/gophercloud/gophercloud/v2/openstack/dns/v2/zones"
14+
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
15+
"github.com/hashicorp/terraform-plugin-testing/terraform"
16+
17+
"github.com/platform9/terraform-provider-pcd/internal/acctest"
18+
)
19+
20+
// TestAccDNSZoneAndRecordSet_basic creates a PRIMARY zone and an A recordset in
21+
// it, updates the recordset, and imports both.
22+
func TestAccDNSZoneAndRecordSet_basic(t *testing.T) {
23+
const zoneName = "pcd_dns_zone.test"
24+
const rrName = "pcd_dns_recordset.test"
25+
26+
resource.Test(t, resource.TestCase{
27+
PreCheck: func() { acctest.PreCheck(t) },
28+
ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories,
29+
CheckDestroy: testAccCheckZoneDestroy(t),
30+
Steps: []resource.TestStep{
31+
{
32+
Config: testAccDNSConfig(`["10.1.0.1", "10.1.0.2"]`),
33+
Check: resource.ComposeAggregateTestCheckFunc(
34+
testAccCheckZoneExists(t, zoneName),
35+
resource.TestCheckResourceAttr(zoneName, "name", "tf-acc-example.com."),
36+
resource.TestCheckResourceAttr(zoneName, "status", "ACTIVE"),
37+
resource.TestCheckResourceAttrSet(zoneName, "serial"),
38+
resource.TestCheckResourceAttr(rrName, "type", "A"),
39+
resource.TestCheckResourceAttr(rrName, "records.#", "2"),
40+
resource.TestCheckResourceAttrPair(rrName, "zone_id", zoneName, "id"),
41+
resource.TestCheckResourceAttrPair("data.pcd_dns_zone.by_name", "id", zoneName, "id"),
42+
),
43+
},
44+
{
45+
Config: testAccDNSConfig(`["10.1.0.3"]`),
46+
Check: resource.TestCheckResourceAttr(rrName, "records.#", "1"),
47+
},
48+
{ResourceName: zoneName, ImportState: true, ImportStateVerify: true},
49+
{
50+
ResourceName: rrName,
51+
ImportState: true,
52+
ImportStateVerify: true,
53+
ImportStateIdFunc: func(s *terraform.State) (string, error) {
54+
rs := s.RootModule().Resources[rrName]
55+
if rs == nil {
56+
return "", fmt.Errorf("not found in state: %s", rrName)
57+
}
58+
return rs.Primary.Attributes["zone_id"] + "/" + rs.Primary.ID, nil
59+
},
60+
},
61+
},
62+
})
63+
}
64+
65+
func testAccDNSConfig(records string) string {
66+
return fmt.Sprintf(`
67+
resource "pcd_dns_zone" "test" {
68+
name = "tf-acc-example.com."
69+
email = "admin@tf-acc-example.com"
70+
ttl = 3600
71+
}
72+
73+
resource "pcd_dns_recordset" "test" {
74+
zone_id = pcd_dns_zone.test.id
75+
name = "www.tf-acc-example.com."
76+
type = "A"
77+
ttl = 300
78+
records = %s
79+
}
80+
81+
data "pcd_dns_zone" "by_name" {
82+
name = pcd_dns_zone.test.name
83+
}
84+
`, records)
85+
}
86+
87+
func testAccCheckZoneExists(t *testing.T, n string) resource.TestCheckFunc {
88+
return func(s *terraform.State) error {
89+
rs, ok := s.RootModule().Resources[n]
90+
if !ok {
91+
return fmt.Errorf("not found in state: %s", n)
92+
}
93+
client, err := acctest.LabConfig(t).DNSV2Client()
94+
if err != nil {
95+
return err
96+
}
97+
if _, err := zones.Get(context.Background(), client, rs.Primary.ID).Extract(); err != nil {
98+
return fmt.Errorf("zone %s not found via API: %w", rs.Primary.ID, err)
99+
}
100+
return nil
101+
}
102+
}
103+
104+
func testAccCheckZoneDestroy(t *testing.T) resource.TestCheckFunc {
105+
return func(s *terraform.State) error {
106+
client, err := acctest.LabConfig(t).DNSV2Client()
107+
if err != nil {
108+
return err
109+
}
110+
for _, rs := range s.RootModule().Resources {
111+
if rs.Type != "pcd_dns_zone" {
112+
continue
113+
}
114+
_, err := zones.Get(context.Background(), client, rs.Primary.ID).Extract()
115+
if err == nil {
116+
return fmt.Errorf("zone %s still exists", rs.Primary.ID)
117+
}
118+
if !gophercloud.ResponseCodeIs(err, http.StatusNotFound) {
119+
return fmt.Errorf("unexpected error checking zone %s: %w", rs.Primary.ID, err)
120+
}
121+
}
122+
return nil
123+
}
124+
}

0 commit comments

Comments
 (0)