Skip to content

Commit f3ccc92

Browse files
committed
test: add DNS record reap round-trip suite for #283
garbageCollectDNSRecordSets and cleanupDNSRecordSets were only ever unit-tested in isolation; nothing drove the reconcile-level round trip that actually reaps a stale hostname's DNS record in production. Add three tests pinning the #283 behaviors: Key changes: - TestEnsureDNSRecordSets_HostnameRemovalReapsRecord: two ensureDNSRecordSets passes (hostname claimed, then removed) must GC the stale record while a still-claimed sibling survives; covers both CNAME (non-apex) and ALIAS (apex) record types - TestCleanupDNSRecordSets_OnGatewayDeletion: Gateway finalization must reap all of its own DNSRecordSets without touching another gateway's records - TestHTTPProxyDeletionCascadesDNSRecordCleanup: drives the real HTTPProxy reconcile to prove the Gateway owner-reference wiring native GC depends on, then simulates the GC cascade (the fake client runs no GC controller) and asserts cleanupDNSRecordSets reaps the child Gateway's records All three pass on origin/main today, which narrows the #283 root cause: the reap mechanisms themselves are correct, so the bug is upstream in how claimedHostnames is computed/retained across reconciles, not in garbage collection. This is a test-only commit for a draft PR; it merges into the #283 fix branch and is not meant to land standalone.
1 parent 091ec54 commit f3ccc92

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)