Skip to content

Commit 8433ffd

Browse files
authored
Merge pull request #285 from datum-cloud/test/283-dns-reap-suite
test: DNS record reap suite for #283 (stacked on #284)
2 parents 091ec54 + f3ccc92 commit 8433ffd

1 file changed

Lines changed: 368 additions & 0 deletions

File tree

Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
// SPDX-License-Identifier: AGPL-3.0-only
2+
3+
package controller
4+
5+
import (
6+
"context"
7+
"testing"
8+
9+
envoygatewayv1alpha1 "github.com/envoyproxy/gateway/api/v1alpha1"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
corev1 "k8s.io/api/core/v1"
13+
discoveryv1 "k8s.io/api/discovery/v1"
14+
apierrors "k8s.io/apimachinery/pkg/api/errors"
15+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
16+
"k8s.io/apimachinery/pkg/runtime"
17+
"k8s.io/apimachinery/pkg/util/uuid"
18+
"k8s.io/client-go/kubernetes/scheme"
19+
"sigs.k8s.io/controller-runtime/pkg/client"
20+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
21+
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
22+
"sigs.k8s.io/controller-runtime/pkg/log"
23+
"sigs.k8s.io/controller-runtime/pkg/log/zap"
24+
"sigs.k8s.io/controller-runtime/pkg/reconcile"
25+
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
26+
mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile"
27+
28+
networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha"
29+
networkingv1alpha1 "go.datum.net/network-services-operator/api/v1alpha1"
30+
"go.datum.net/network-services-operator/internal/config"
31+
dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1"
32+
)
33+
34+
// ---------------------------------------------------------------------------
35+
// This file pins the test-coverage gap called out in
36+
// https://github.com/datum-cloud/network-services-operator/issues/283:
37+
// garbageCollectDNSRecordSets and cleanupDNSRecordSets were only ever
38+
// exercised in isolation, never through the reconcile-level round trip that
39+
// actually reaps a stale hostname's DNS record in production. Each test below
40+
// documents the #283 behavior it pins and its expected pass/fail status on
41+
// origin/main.
42+
// ---------------------------------------------------------------------------
43+
44+
// TestEnsureDNSRecordSets_HostnameRemovalReapsRecord pins #283 scenario 1:
45+
// the reconcile-level round trip. A hostname that disappears from a
46+
// Gateway's claimed hostnames between two ensureDNSRecordSets calls
47+
// (mirroring two passes of the gateway controller's Reconcile loop) must
48+
// have its DNSRecordSet garbage collected, while a hostname that remains
49+
// claimed keeps its record. Covers both CNAME (non-apex) and ALIAS (apex)
50+
// record types.
51+
//
52+
// Expected GREEN on origin/main: garbageCollectDNSRecordSets already runs at
53+
// the tail of every ensureDNSRecordSets call (gateway_dns_controller.go:382),
54+
// scoped to the hostnames passed into that same call, so the reap already
55+
// works at this level. If this test goes red, the create/GC pairing inside
56+
// ensureDNSRecordSets is broken. If it stays green, the real #283 gap is
57+
// upstream of this function -- in how claimedHostnames survives (or fails to
58+
// drop a removed hostname) across reconciles, which this test does not
59+
// exercise (see ensureHostnamesClaimed in gateway_controller.go).
60+
func TestEnsureDNSRecordSets_HostnameRemovalReapsRecord(t *testing.T) {
61+
const ns = "test-ns"
62+
ctx := log.IntoContext(context.Background(), zap.New())
63+
64+
testConfig := config.NetworkServicesOperator{
65+
Gateway: config.GatewayConfig{
66+
TargetDomain: "gateways.test.local",
67+
EnableDNSIntegration: true,
68+
},
69+
}
70+
71+
tests := []struct {
72+
name string
73+
removedHostname string
74+
keptHostname string
75+
upstreamObjects []client.Object
76+
wantRemovedRRType dnsv1alpha1.RRType
77+
}{
78+
{
79+
name: "non-apex CNAME hostname removed",
80+
removedHostname: "remove.example.com",
81+
keptHostname: "keep.example.com",
82+
upstreamObjects: []client.Object{
83+
newVerifiedDNSZoneDomain(ns, "example.com", false),
84+
newDNSZone(ns, "example-com", "example.com"),
85+
},
86+
wantRemovedRRType: dnsv1alpha1.RRTypeCNAME,
87+
},
88+
{
89+
name: "apex ALIAS hostname removed, sibling CNAME survives",
90+
removedHostname: "example.com",
91+
keptHostname: "keep.example.net",
92+
upstreamObjects: []client.Object{
93+
newVerifiedDNSZoneDomain(ns, "example.com", true),
94+
newDNSZone(ns, "example-com", "example.com"),
95+
newVerifiedDNSZoneDomain(ns, "example.net", false),
96+
newDNSZone(ns, "example-net", "example.net"),
97+
},
98+
wantRemovedRRType: dnsv1alpha1.RRTypeALIAS,
99+
},
100+
}
101+
102+
for _, tt := range tests {
103+
t.Run(tt.name, func(t *testing.T) {
104+
s := newDNSTestScheme(t)
105+
gw := newTestGatewayForDNS(ns, "roundtrip-gw")
106+
107+
allObjects := append([]client.Object{gw}, tt.upstreamObjects...)
108+
for _, obj := range allObjects {
109+
if obj.GetUID() == "" {
110+
obj.SetUID(uuid.NewUUID())
111+
}
112+
if obj.GetCreationTimestamp().Time.IsZero() {
113+
obj.SetCreationTimestamp(metav1.Now())
114+
}
115+
}
116+
117+
cl := buildFakeUpstreamClientForDNS(s, allObjects...)
118+
reconciler := newDNSReconciler(testConfig)
119+
120+
removedRSName := dnsRecordSetName(gw.Name, tt.removedHostname)
121+
keptRSName := dnsRecordSetName(gw.Name, tt.keptHostname)
122+
123+
// First reconcile pass: both hostnames are claimed.
124+
statuses, result := reconciler.ensureDNSRecordSets(ctx, cl, gw, []string{tt.removedHostname, tt.keptHostname})
125+
require.NoError(t, result.Err)
126+
require.Len(t, statuses, 2)
127+
128+
var removedRS dnsv1alpha1.DNSRecordSet
129+
require.NoError(t, cl.Get(ctx, client.ObjectKey{Namespace: ns, Name: removedRSName}, &removedRS),
130+
"record for %q should exist after the first pass", tt.removedHostname)
131+
assert.Equal(t, tt.wantRemovedRRType, removedRS.Spec.RecordType)
132+
133+
var keptRS dnsv1alpha1.DNSRecordSet
134+
require.NoError(t, cl.Get(ctx, client.ObjectKey{Namespace: ns, Name: keptRSName}, &keptRS),
135+
"record for %q should exist after the first pass", tt.keptHostname)
136+
137+
// Second reconcile pass: the hostname was removed from the Gateway.
138+
_, result = reconciler.ensureDNSRecordSets(ctx, cl, gw, []string{tt.keptHostname})
139+
require.NoError(t, result.Err)
140+
141+
err := cl.Get(ctx, client.ObjectKey{Namespace: ns, Name: removedRSName}, &dnsv1alpha1.DNSRecordSet{})
142+
assert.True(t, apierrors.IsNotFound(err),
143+
"DNSRecordSet for removed hostname %q should have been reaped (#283); got err=%v", tt.removedHostname, err)
144+
145+
require.NoError(t, cl.Get(ctx, client.ObjectKey{Namespace: ns, Name: keptRSName}, &keptRS),
146+
"record for still-claimed hostname %q must survive the reap", tt.keptHostname)
147+
})
148+
}
149+
}
150+
151+
// TestCleanupDNSRecordSets_OnGatewayDeletion pins #283 scenario 2: deleting a
152+
// Gateway must reap every DNSRecordSet it owns via cleanupDNSRecordSets, the
153+
// function finalizeGateway invokes during finalization
154+
// (gateway_controller.go:1598). Also asserts scope: a DNSRecordSet belonging
155+
// to a different Gateway must survive.
156+
//
157+
// Expected GREEN on origin/main: cleanupDNSRecordSets unconditionally lists
158+
// and deletes by the (managed, managed-by, source-kind=Gateway, source-name,
159+
// source-namespace) label set -- no bug is visible at this layer, matching
160+
// the issue's note that the HTTPProxy/Gateway-delete cleanup path has been
161+
// fixed since v0.17.0.
162+
func TestCleanupDNSRecordSets_OnGatewayDeletion(t *testing.T) {
163+
const ns = "test-ns"
164+
ctx := log.IntoContext(context.Background(), zap.New())
165+
s := newDNSTestScheme(t)
166+
167+
testConfig := config.NetworkServicesOperator{
168+
Gateway: config.GatewayConfig{
169+
TargetDomain: "gateways.test.local",
170+
EnableDNSIntegration: true,
171+
},
172+
}
173+
174+
gw := newTestGatewayForDNS(ns, "deleted-gw")
175+
now := metav1.Now()
176+
gw.DeletionTimestamp = &now
177+
gw.Finalizers = []string{"networking.datumapis.com/gateway-cleanup"}
178+
179+
otherGW := newTestGatewayForDNS(ns, "other-gw")
180+
181+
makeRS := func(name, sourceGWName, hostname string) *dnsv1alpha1.DNSRecordSet {
182+
return &dnsv1alpha1.DNSRecordSet{
183+
ObjectMeta: metav1.ObjectMeta{
184+
Namespace: ns,
185+
Name: name,
186+
UID: uuid.NewUUID(),
187+
Labels: map[string]string{
188+
labelManagedBy: labelManagedByValue,
189+
labelDNSManaged: labelValueTrue,
190+
labelDNSSourceKind: KindGateway,
191+
labelDNSSourceName: sourceGWName,
192+
labelDNSSourceNS: ns,
193+
},
194+
Annotations: map[string]string{
195+
annotationDNSHostname: hostname,
196+
},
197+
},
198+
Spec: dnsv1alpha1.DNSRecordSetSpec{
199+
DNSZoneRef: corev1.LocalObjectReference{Name: "example-com"},
200+
RecordType: dnsv1alpha1.RRTypeCNAME,
201+
Records: []dnsv1alpha1.RecordEntry{{Name: hostname + "."}},
202+
},
203+
}
204+
}
205+
206+
deletedGWRecordA := makeRS(dnsRecordSetName(gw.Name, "a.example.com"), gw.Name, "a.example.com")
207+
deletedGWRecordB := makeRS(dnsRecordSetName(gw.Name, "b.example.com"), gw.Name, "b.example.com")
208+
survivingRecord := makeRS(dnsRecordSetName(otherGW.Name, "c.example.com"), otherGW.Name, "c.example.com")
209+
210+
allObjects := []client.Object{gw, otherGW, deletedGWRecordA, deletedGWRecordB, survivingRecord}
211+
for _, obj := range allObjects {
212+
if obj.GetUID() == "" {
213+
obj.SetUID(uuid.NewUUID())
214+
}
215+
if obj.GetCreationTimestamp().Time.IsZero() {
216+
obj.SetCreationTimestamp(metav1.Now())
217+
}
218+
}
219+
220+
cl := buildFakeUpstreamClientForDNS(s, allObjects...)
221+
reconciler := newDNSReconciler(testConfig)
222+
223+
result := reconciler.cleanupDNSRecordSets(ctx, cl, gw)
224+
require.NoError(t, result.Err)
225+
226+
var remaining dnsv1alpha1.DNSRecordSetList
227+
require.NoError(t, cl.List(ctx, &remaining, client.InNamespace(ns)))
228+
229+
remainingNames := make([]string, 0, len(remaining.Items))
230+
for _, rs := range remaining.Items {
231+
remainingNames = append(remainingNames, rs.Name)
232+
}
233+
assert.NotContains(t, remainingNames, deletedGWRecordA.Name, "record for deleted gateway should be reaped (#283)")
234+
assert.NotContains(t, remainingNames, deletedGWRecordB.Name, "record for deleted gateway should be reaped (#283)")
235+
assert.Contains(t, remainingNames, survivingRecord.Name, "record belonging to a different gateway must not be touched")
236+
}
237+
238+
// TestHTTPProxyDeletionCascadesDNSRecordCleanup pins #283 scenario 3: an
239+
// HTTPProxy delete must eventually reap the DNS records created for its
240+
// child Gateway. In production this happens because the HTTPProxy
241+
// controller sets itself as the Gateway's Controller owner reference
242+
// (httpproxy_controller.go, collectDesiredResources/SetControllerReference),
243+
// native Kubernetes garbage collection then deletes the Gateway once the
244+
// HTTPProxy is gone, and Gateway deletion runs finalizeGateway ->
245+
// cleanupDNSRecordSets.
246+
//
247+
// The fake client used here has no garbage-collector loop, so this test:
248+
// 1. Drives the real HTTPProxy reconcile to prove the owner-reference
249+
// wiring that native GC depends on is actually correct.
250+
// 2. Simulates the GC cascade by deleting the owned Gateway directly --
251+
// what real Kubernetes garbage collection guarantees given the owner
252+
// reference asserted in step 1.
253+
// 3. Calls cleanupDNSRecordSets, the same function the real Gateway
254+
// finalizer invokes, to assert the DNS records are reaped.
255+
//
256+
// Expected GREEN on origin/main: both the owner-reference wiring and
257+
// cleanupDNSRecordSets are individually correct, so this simulated cascade
258+
// succeeds. This does NOT prove the real end-to-end cascade is bug-free --
259+
// only a chainsaw/e2e test exercising real garbage collection can prove
260+
// that. See #283's "no chainsaw e2e references DNSRecordSet at all" gap.
261+
func TestHTTPProxyDeletionCascadesDNSRecordCleanup(t *testing.T) {
262+
const ns = "test"
263+
ctx := log.IntoContext(context.Background(), zap.New())
264+
265+
testScheme := runtime.NewScheme()
266+
require.NoError(t, scheme.AddToScheme(testScheme))
267+
require.NoError(t, gatewayv1.Install(testScheme))
268+
require.NoError(t, envoygatewayv1alpha1.AddToScheme(testScheme))
269+
require.NoError(t, discoveryv1.AddToScheme(testScheme))
270+
require.NoError(t, networkingv1alpha.AddToScheme(testScheme))
271+
require.NoError(t, networkingv1alpha1.AddToScheme(testScheme))
272+
require.NoError(t, dnsv1alpha1.AddToScheme(testScheme))
273+
274+
testConfig := config.NetworkServicesOperator{
275+
HTTPProxy: config.HTTPProxyConfig{
276+
GatewayClassName: "test-gateway-class",
277+
},
278+
Gateway: config.GatewayConfig{
279+
ControllerName: gatewayv1.GatewayController("test-gateway-class"),
280+
DownstreamGatewayClassName: "test-downstream-gateway-class",
281+
TargetDomain: "gateways.test.local",
282+
EnableDNSIntegration: true,
283+
},
284+
}
285+
286+
httpProxy := newHTTPProxy()
287+
namespace := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ns, UID: uuid.NewUUID()}}
288+
289+
fakeClient := fake.NewClientBuilder().
290+
WithScheme(testScheme).
291+
WithObjects(httpProxy, namespace).
292+
WithStatusSubresource(httpProxy, &gatewayv1.Gateway{}).
293+
WithInterceptorFuncs(interceptor.Funcs{
294+
Create: func(ctx context.Context, cl client.WithWatch, obj client.Object, opts ...client.CreateOption) error {
295+
obj.SetUID(uuid.NewUUID())
296+
obj.SetCreationTimestamp(metav1.Now())
297+
return cl.Create(ctx, obj, opts...)
298+
},
299+
}).
300+
Build()
301+
302+
httpProxyReconciler := &HTTPProxyReconciler{
303+
mgr: &fakeMockManager{cl: fakeClient},
304+
Config: testConfig,
305+
}
306+
307+
req := mcreconcile.Request{
308+
Request: reconcile.Request{
309+
NamespacedName: client.ObjectKeyFromObject(httpProxy),
310+
},
311+
ClusterName: "test-cluster",
312+
}
313+
314+
// Pass 1 adds the HTTPProxy finalizer; pass 2 creates the owned Gateway.
315+
_, err := httpProxyReconciler.Reconcile(ctx, req)
316+
require.NoError(t, err)
317+
_, err = httpProxyReconciler.Reconcile(ctx, req)
318+
require.NoError(t, err)
319+
320+
var gateway gatewayv1.Gateway
321+
require.NoError(t, fakeClient.Get(ctx, client.ObjectKeyFromObject(httpProxy), &gateway))
322+
323+
ownerRef := metav1.GetControllerOf(&gateway)
324+
require.NotNil(t, ownerRef, "Gateway must have a controller owner reference for native GC to cascade-delete it")
325+
assert.Equal(t, httpProxy.Name, ownerRef.Name)
326+
assert.Equal(t, "HTTPProxy", ownerRef.Kind)
327+
328+
// Seed a DNS record as if a prior GatewayReconciler pass had already run
329+
// ensureDNSRecordSets for this Gateway.
330+
rs := &dnsv1alpha1.DNSRecordSet{
331+
ObjectMeta: metav1.ObjectMeta{
332+
Namespace: ns,
333+
Name: dnsRecordSetName(gateway.Name, "api.example.com"),
334+
UID: uuid.NewUUID(),
335+
Labels: map[string]string{
336+
labelManagedBy: labelManagedByValue,
337+
labelDNSManaged: labelValueTrue,
338+
labelDNSSourceKind: KindGateway,
339+
labelDNSSourceName: gateway.Name,
340+
labelDNSSourceNS: ns,
341+
},
342+
Annotations: map[string]string{
343+
annotationDNSHostname: "api.example.com",
344+
},
345+
},
346+
Spec: dnsv1alpha1.DNSRecordSetSpec{
347+
DNSZoneRef: corev1.LocalObjectReference{Name: "example-com"},
348+
RecordType: dnsv1alpha1.RRTypeCNAME,
349+
Records: []dnsv1alpha1.RecordEntry{{Name: "api.example.com."}},
350+
},
351+
}
352+
require.NoError(t, fakeClient.Create(ctx, rs))
353+
354+
// Delete the HTTPProxy. The fake client runs no garbage-collector, so
355+
// explicitly delete the owned Gateway to simulate the cascade the owner
356+
// reference asserted above guarantees in a real cluster.
357+
require.NoError(t, fakeClient.Delete(ctx, httpProxy))
358+
require.NoError(t, fakeClient.Delete(ctx, &gateway))
359+
360+
gatewayReconciler := &GatewayReconciler{Config: testConfig}
361+
cleanupResult := gatewayReconciler.cleanupDNSRecordSets(ctx, fakeClient, &gateway)
362+
require.NoError(t, cleanupResult.Err)
363+
364+
var remaining dnsv1alpha1.DNSRecordSetList
365+
require.NoError(t, fakeClient.List(ctx, &remaining, client.InNamespace(ns)))
366+
assert.Empty(t, remaining.Items,
367+
"DNS records owned by the HTTPProxy's Gateway should be reaped once the cascade reaches cleanupDNSRecordSets (#283)")
368+
}

0 commit comments

Comments
 (0)