Skip to content

Commit 0473f87

Browse files
authored
Merge pull request #111 from datum-cloud/feat/dns-integration
feat: automatic DNS record management for gateway hostnames
2 parents c38b842 + a87305c commit 0473f87

19 files changed

+2810
-49
lines changed

api/v1alpha/httpproxy_types.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,25 @@ type ConnectorReference struct {
177177
Name string `json:"name"`
178178
}
179179

180+
// HostnameStatus captures the per-hostname verification and DNS programming status.
181+
// Each hostname configured on an HTTPProxy has a corresponding entry tracking
182+
// its lifecycle from domain ownership verification through DNS record creation.
183+
type HostnameStatus struct {
184+
// Hostname is the fully qualified domain name being tracked.
185+
// Must be a valid RFC 1123 hostname without a trailing dot.
186+
//
187+
// +kubebuilder:validation:Required
188+
Hostname string `json:"hostname"`
189+
190+
// Conditions contains the current status conditions for this hostname.
191+
// Standard condition types include Verified and DNSRecordProgrammed.
192+
//
193+
// +listType=map
194+
// +listMapKey=type
195+
// +optional
196+
Conditions []metav1.Condition `json:"conditions,omitempty"`
197+
}
198+
180199
// HTTPProxyStatus defines the observed state of HTTPProxy.
181200
type HTTPProxyStatus struct {
182201
// Addresses lists the network addresses that have been bound to the
@@ -192,8 +211,28 @@ type HTTPProxyStatus struct {
192211
//
193212
// If this list does not match that defined in the HTTPProxy, see the
194213
// `HostnamesVerified` condition message for details.
214+
//
215+
// Deprecated: Use HostnameStatuses for detailed per-hostname status.
216+
// This field will be removed in a future API version.
195217
Hostnames []gatewayv1.Hostname `json:"hostnames,omitempty"`
196218

219+
// CanonicalHostname is the platform-managed stable hostname assigned to this
220+
// HTTPProxy (e.g., "<uid>.datumproxy.net"). Users may create external CNAME
221+
// or ALIAS records pointing to this hostname to route traffic through the
222+
// platform. The platform manages A/AAAA records for this hostname in the
223+
// datumproxy.net zone.
224+
//
225+
// +optional
226+
CanonicalHostname string `json:"canonicalHostname,omitempty"`
227+
228+
// HostnameStatuses lists the per-hostname status for each hostname configured
229+
// on this HTTPProxy. Each entry includes verification and DNS record
230+
// programming conditions. Use this field instead of the deprecated Hostnames
231+
// field for detailed per-hostname lifecycle information.
232+
//
233+
// +optional
234+
HostnameStatuses []HostnameStatus `json:"hostnameStatuses,omitempty"`
235+
197236
// Conditions describe the current conditions of the HTTPProxy.
198237
//
199238
// +listType=map
@@ -224,6 +263,96 @@ const (
224263
HTTPProxyConditionConnectorMetadataProgrammed = "ConnectorMetadataProgrammed"
225264
)
226265

266+
const (
267+
// HTTPProxyConditionDNSRecordsProgrammed is an aggregate condition that is
268+
// True when all hostnames using Datum-managed DNS have records programmed,
269+
// False when one or more hostnames failed DNS record creation, and omitted
270+
// when no hostnames use Datum-managed DNS.
271+
HTTPProxyConditionDNSRecordsProgrammed = "DNSRecordsProgrammed"
272+
)
273+
274+
// Per-hostname condition types (used in HostnameStatus.Conditions).
275+
const (
276+
// HostnameConditionVerified tracks domain ownership verification.
277+
HostnameConditionVerified = "Verified"
278+
279+
// HostnameConditionDNSRecordProgrammed tracks whether a DNS record was
280+
// created in the DNSZone for this hostname.
281+
HostnameConditionDNSRecordProgrammed = "DNSRecordProgrammed"
282+
283+
// HostnameConditionAvailable indicates whether the hostname was successfully
284+
// claimed by this resource or is already in use by another resource.
285+
HostnameConditionAvailable = "Available"
286+
)
287+
288+
// Reasons for HostnameConditionAvailable.
289+
const (
290+
// HostnameAvailableReasonClaimed indicates the hostname was successfully claimed.
291+
HostnameAvailableReasonClaimed = "Claimed"
292+
293+
// HostnameAvailableReasonInUse indicates the hostname is already claimed by
294+
// another Gateway or HTTPProxy.
295+
HostnameAvailableReasonInUse = "InUse"
296+
)
297+
298+
// Reasons for HostnameConditionDNSRecordProgrammed.
299+
const (
300+
// DNSRecordReasonCreated indicates a DNS record was successfully created.
301+
DNSRecordReasonCreated = "RecordCreated"
302+
303+
// DNSRecordReasonPending indicates a DNS record is pending programming.
304+
DNSRecordReasonPending = "Pending"
305+
306+
// DNSRecordReasonUpdated indicates an existing platform-managed DNS record
307+
// was successfully updated.
308+
DNSRecordReasonUpdated = "RecordUpdated"
309+
310+
// DNSRecordReasonZoneNotFound indicates no DNSZone manages this hostname's
311+
// apex domain. This is not an error; the condition is set to True.
312+
DNSRecordReasonZoneNotFound = "DNSZoneNotFound"
313+
314+
// DNSRecordReasonZoneNotReady indicates a DNSZone exists but is not yet
315+
// accepted and programmed.
316+
DNSRecordReasonZoneNotReady = "DNSZoneNotReady"
317+
318+
// DNSRecordReasonDomainNotVerified indicates the Domain resource for this
319+
// hostname has not been verified via a DNSZone.
320+
DNSRecordReasonDomainNotVerified = "DomainNotVerified"
321+
322+
// DNSRecordReasonConflict indicates an existing DNSRecordSet for this
323+
// hostname is managed by a different actor.
324+
DNSRecordReasonConflict = "ConflictWithUserRecord"
325+
326+
// DNSRecordReasonFailed indicates an API error when creating or updating
327+
// the DNSRecordSet.
328+
DNSRecordReasonFailed = "RecordCreationFailed"
329+
330+
// DNSRecordReasonRetryPending indicates a transient error; the controller
331+
// will retry.
332+
DNSRecordReasonRetryPending = "RetryPending"
333+
334+
// DNSRecordReasonNotApplicable indicates the domain is not managed by
335+
// Datum DNS. No record is created; the condition is set to True.
336+
DNSRecordReasonNotApplicable = "NotApplicable"
337+
)
338+
339+
// Reasons for HTTPProxyConditionDNSRecordsProgrammed.
340+
const (
341+
// DNSRecordsProgrammedReasonAllCreated indicates that every hostname
342+
// requiring a DNS record has had its record successfully created or updated.
343+
DNSRecordsProgrammedReasonAllCreated = "AllRecordsCreated"
344+
345+
// DNSRecordsProgrammedReasonAllApplicableCreated indicates that all
346+
// hostnames that use Datum-managed DNS have had records successfully created
347+
// or updated. Hostnames not using Datum DNS are not counted.
348+
DNSRecordsProgrammedReasonAllApplicableCreated = "AllApplicableRecordsCreated"
349+
350+
// DNSRecordsProgrammedReasonPartialFailure indicates that one or more
351+
// hostnames that require DNS records could not have their records created or
352+
// updated. See per-hostname conditions for details.
353+
DNSRecordsProgrammedReasonPartialFailure = "PartialFailure"
354+
)
355+
227356
const (
228357

229358
// HTTPProxyReasonAccepted indicates that the HTTP proxy has been accepted.
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
// SPDX-License-Identifier: AGPL-3.0-only
2+
3+
package v1alpha_test
4+
5+
import (
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
11+
"k8s.io/utils/ptr"
12+
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
13+
14+
networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha"
15+
)
16+
17+
const testMutatedHostname = "mutated.example.com"
18+
19+
// TestHostnameStatus_DeepCopy verifies that DeepCopy produces an independent
20+
// copy of a HostnameStatus value with all nested slices properly duplicated.
21+
func TestHostnameStatus_DeepCopy(t *testing.T) {
22+
t.Parallel()
23+
24+
original := networkingv1alpha.HostnameStatus{
25+
Hostname: "api.example.com",
26+
Conditions: []metav1.Condition{
27+
{
28+
Type: networkingv1alpha.HostnameConditionDNSRecordProgrammed,
29+
Status: metav1.ConditionTrue,
30+
Reason: networkingv1alpha.DNSRecordReasonCreated,
31+
Message: "cname record created in DNSZone \"example-com\"",
32+
ObservedGeneration: 1,
33+
},
34+
{
35+
Type: networkingv1alpha.HostnameConditionVerified,
36+
Status: metav1.ConditionTrue,
37+
Reason: "DomainVerified",
38+
ObservedGeneration: 1,
39+
},
40+
},
41+
}
42+
43+
copied := original.DeepCopy()
44+
require.NotNil(t, copied)
45+
46+
// Values must be equal.
47+
assert.Equal(t, original.Hostname, copied.Hostname)
48+
assert.Equal(t, original.Conditions, copied.Conditions)
49+
50+
// Mutating the copy must not affect the original.
51+
copied.Hostname = testMutatedHostname
52+
copied.Conditions[0].Message = "mutated message"
53+
copied.Conditions = append(copied.Conditions, metav1.Condition{Type: "Extra"})
54+
55+
assert.Equal(t, "api.example.com", original.Hostname)
56+
assert.Equal(t, "cname record created in DNSZone \"example-com\"", original.Conditions[0].Message)
57+
assert.Len(t, original.Conditions, 2, "original conditions should not gain extra element")
58+
}
59+
60+
// TestHostnameStatus_DeepCopy_Nil verifies that DeepCopy on a nil pointer
61+
// returns nil rather than panicking.
62+
func TestHostnameStatus_DeepCopy_Nil(t *testing.T) {
63+
t.Parallel()
64+
65+
var hs *networkingv1alpha.HostnameStatus
66+
assert.Nil(t, hs.DeepCopy())
67+
}
68+
69+
// TestHostnameStatus_DeepCopy_EmptyConditions verifies that a HostnameStatus
70+
// with an empty (but non-nil) Conditions slice is deep-copied correctly.
71+
func TestHostnameStatus_DeepCopy_EmptyConditions(t *testing.T) {
72+
t.Parallel()
73+
74+
original := networkingv1alpha.HostnameStatus{
75+
Hostname: "example.com",
76+
Conditions: []metav1.Condition{},
77+
}
78+
copied := original.DeepCopy()
79+
require.NotNil(t, copied)
80+
assert.Equal(t, original.Hostname, copied.Hostname)
81+
assert.NotNil(t, copied.Conditions, "empty slice should be preserved as non-nil")
82+
}
83+
84+
// TestHTTPProxyStatus_DeepCopy verifies that HTTPProxyStatus deep-copies all
85+
// nested slices correctly.
86+
func TestHTTPProxyStatus_DeepCopy(t *testing.T) {
87+
t.Parallel()
88+
89+
original := networkingv1alpha.HTTPProxyStatus{
90+
CanonicalHostname: "abc123.gateways.test.local",
91+
Addresses: []gatewayv1.GatewayStatusAddress{
92+
{
93+
Type: ptr.To(gatewayv1.HostnameAddressType),
94+
Value: "abc123.gateways.test.local",
95+
},
96+
{
97+
Type: ptr.To(gatewayv1.IPAddressType),
98+
Value: "192.168.1.10",
99+
},
100+
},
101+
HostnameStatuses: []networkingv1alpha.HostnameStatus{
102+
{
103+
Hostname: "api.example.com",
104+
Conditions: []metav1.Condition{
105+
{
106+
Type: networkingv1alpha.HostnameConditionDNSRecordProgrammed,
107+
Status: metav1.ConditionTrue,
108+
Reason: networkingv1alpha.DNSRecordReasonCreated,
109+
},
110+
},
111+
},
112+
},
113+
Conditions: []metav1.Condition{
114+
{
115+
Type: networkingv1alpha.HTTPProxyConditionDNSRecordsProgrammed,
116+
Status: metav1.ConditionTrue,
117+
Reason: networkingv1alpha.DNSRecordsProgrammedReasonAllCreated,
118+
},
119+
},
120+
}
121+
122+
copied := original.DeepCopy()
123+
require.NotNil(t, copied)
124+
125+
// Top-level value equality.
126+
assert.Equal(t, original.CanonicalHostname, copied.CanonicalHostname)
127+
assert.Equal(t, original.Addresses, copied.Addresses)
128+
assert.Equal(t, original.HostnameStatuses, copied.HostnameStatuses)
129+
assert.Equal(t, original.Conditions, copied.Conditions)
130+
131+
// Mutation independence – addresses.
132+
copied.Addresses[0].Value = "mutated"
133+
assert.Equal(t, "abc123.gateways.test.local", original.Addresses[0].Value)
134+
135+
// Mutation independence – hostname statuses.
136+
copied.HostnameStatuses[0].Hostname = testMutatedHostname
137+
assert.Equal(t, "api.example.com", original.HostnameStatuses[0].Hostname)
138+
139+
// Mutation independence – conditions.
140+
copied.Conditions[0].Reason = "Mutated"
141+
assert.Equal(t, networkingv1alpha.DNSRecordsProgrammedReasonAllCreated, original.Conditions[0].Reason)
142+
}
143+
144+
// TestHTTPProxyStatus_DeepCopy_Nil verifies that DeepCopy on a nil pointer
145+
// returns nil rather than panicking.
146+
func TestHTTPProxyStatus_DeepCopy_Nil(t *testing.T) {
147+
t.Parallel()
148+
149+
var s *networkingv1alpha.HTTPProxyStatus
150+
assert.Nil(t, s.DeepCopy())
151+
}
152+
153+
// TestHTTPProxy_DeepCopy verifies that the full HTTPProxy object is deep-copied
154+
// correctly (round-trip: copy, mutate, verify original unchanged).
155+
func TestHTTPProxy_DeepCopy(t *testing.T) {
156+
t.Parallel()
157+
158+
original := &networkingv1alpha.HTTPProxy{
159+
ObjectMeta: metav1.ObjectMeta{
160+
Name: "my-proxy",
161+
Namespace: "default",
162+
},
163+
Spec: networkingv1alpha.HTTPProxySpec{
164+
Hostnames: []gatewayv1.Hostname{"api.example.com", "example.com"},
165+
},
166+
Status: networkingv1alpha.HTTPProxyStatus{
167+
CanonicalHostname: "abc123.gateways.test.local",
168+
HostnameStatuses: []networkingv1alpha.HostnameStatus{
169+
{
170+
Hostname: "api.example.com",
171+
Conditions: []metav1.Condition{
172+
{
173+
Type: networkingv1alpha.HostnameConditionDNSRecordProgrammed,
174+
Status: metav1.ConditionTrue,
175+
Reason: networkingv1alpha.DNSRecordReasonCreated,
176+
},
177+
},
178+
},
179+
},
180+
},
181+
}
182+
183+
copied := original.DeepCopy()
184+
require.NotNil(t, copied)
185+
186+
// Value equality.
187+
assert.Equal(t, original.Name, copied.Name)
188+
assert.Equal(t, original.Namespace, copied.Namespace)
189+
assert.Equal(t, original.Spec.Hostnames, copied.Spec.Hostnames)
190+
assert.Equal(t, original.Status.CanonicalHostname, copied.Status.CanonicalHostname)
191+
assert.Equal(t, original.Status.HostnameStatuses, copied.Status.HostnameStatuses)
192+
193+
// Mutation independence.
194+
copied.Status.CanonicalHostname = "mutated"
195+
assert.Equal(t, "abc123.gateways.test.local", original.Status.CanonicalHostname)
196+
197+
copied.Status.HostnameStatuses[0].Hostname = testMutatedHostname
198+
assert.Equal(t, "api.example.com", original.Status.HostnameStatuses[0].Hostname)
199+
200+
copied.Spec.Hostnames[0] = testMutatedHostname
201+
assert.Equal(t, gatewayv1.Hostname("api.example.com"), original.Spec.Hostnames[0])
202+
}
203+
204+
// TestConditionConstants verifies that the expected condition constant strings
205+
// match the documented API values, preventing accidental renames.
206+
func TestConditionConstants(t *testing.T) {
207+
t.Parallel()
208+
209+
assert.Equal(t, "DNSRecordsProgrammed", networkingv1alpha.HTTPProxyConditionDNSRecordsProgrammed)
210+
assert.Equal(t, "Verified", networkingv1alpha.HostnameConditionVerified)
211+
assert.Equal(t, "DNSRecordProgrammed", networkingv1alpha.HostnameConditionDNSRecordProgrammed)
212+
213+
// Reason constants
214+
assert.Equal(t, "RecordCreated", networkingv1alpha.DNSRecordReasonCreated)
215+
assert.Equal(t, "RecordUpdated", networkingv1alpha.DNSRecordReasonUpdated)
216+
assert.Equal(t, "DNSZoneNotFound", networkingv1alpha.DNSRecordReasonZoneNotFound)
217+
assert.Equal(t, "DNSZoneNotReady", networkingv1alpha.DNSRecordReasonZoneNotReady)
218+
assert.Equal(t, "DomainNotVerified", networkingv1alpha.DNSRecordReasonDomainNotVerified)
219+
assert.Equal(t, "ConflictWithUserRecord", networkingv1alpha.DNSRecordReasonConflict)
220+
assert.Equal(t, "RecordCreationFailed", networkingv1alpha.DNSRecordReasonFailed)
221+
assert.Equal(t, "RetryPending", networkingv1alpha.DNSRecordReasonRetryPending)
222+
assert.Equal(t, "NotApplicable", networkingv1alpha.DNSRecordReasonNotApplicable)
223+
224+
assert.Equal(t, "AllRecordsCreated", networkingv1alpha.DNSRecordsProgrammedReasonAllCreated)
225+
assert.Equal(t, "AllApplicableRecordsCreated", networkingv1alpha.DNSRecordsProgrammedReasonAllApplicableCreated)
226+
assert.Equal(t, "PartialFailure", networkingv1alpha.DNSRecordsProgrammedReasonPartialFailure)
227+
}

0 commit comments

Comments
 (0)