Skip to content

Commit f7f42cd

Browse files
committed
fix: handle TPP delete and targetRef removal in retrigger
Complete the TrafficProtectionPolicy arm of the edge re-translation controller so disabling protection reaches the data plane. The spec-change arm only touched current targets, so two cases left stale WAF on the edge: a deleted TPP whose Enforce program kept blocking, and a Gateway dropped from spec.targetRefs that kept the old config. Root cause: standard Reconcile receives only the object key on delete, not the last-known object, so there are no targetRefs to resolve owning Gateways from; and a targetRef edit only exposes the current targets. Key changes: - Record each TPP's owning Gateway set in an in-memory map keyed by namespaced name, populated by the create event every live TPP fires on startup so a restart re-learns the set before any later edit or delete. - On delete, clear the trigger annotation on every previously-owning Gateway; on a targetRef edit, clear the Gateways that dropped out of the set. Removing the annotation is itself a Gateway change, so EG re-translates against the now-empty cache and drops the orphaned WAF. - Admit delete events in the predicate (previously dropped). - On a failed touch of a current target, retain the previous set so dropped Gateways are retried on requeue rather than forgotten. - Tests for deletion, targetRef removal, and the delete predicate. Closes #256
1 parent 80070a9 commit f7f42cd

2 files changed

Lines changed: 203 additions & 25 deletions

File tree

internal/extensionserver/retrigger/tpp.go

Lines changed: 141 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,26 @@
1313
// a trigger annotation on each owning Gateway. Because the touch happens after
1414
// the new spec is already in the shared cache, EG re-translates against fresh
1515
// data and the mode change reaches the edge in seconds.
16+
//
17+
// Deletion and targetRef removal are the mirror image: when a TPP is deleted or
18+
// a Gateway is dropped from spec.targetRefs, the cache no longer programs any WAF
19+
// for that Gateway, but nothing forces EG to re-translate — so the edge keeps
20+
// serving the last (possibly Enforce, still-blocking) program. Standard
21+
// Reconcile receives only the object key on delete, not the last-known object,
22+
// so the controller records the owning Gateway set of each TPP in memory and, on
23+
// delete or a targetRef edit, clears the trigger annotation on every Gateway that
24+
// dropped out of the set. Removing the annotation is itself a Gateway change, so
25+
// EG re-translates against the now-empty cache and drops the orphaned WAF config.
1626

1727
package retrigger
1828

1929
import (
2030
"context"
2131
"fmt"
2232
"strconv"
33+
"sync"
2334

35+
"github.com/go-logr/logr"
2436
"k8s.io/apimachinery/pkg/types"
2537
ctrl "sigs.k8s.io/controller-runtime"
2638
"sigs.k8s.io/controller-runtime/pkg/builder"
@@ -42,9 +54,23 @@ const tppTriggerAnnotationPrefix = "networking.datumapis.com/tpp-generation-"
4254

4355
// TPPReconciler touches the trigger annotation on every Gateway a
4456
// TrafficProtectionPolicy targets whenever the policy's spec changes, forcing EG
45-
// to re-translate and re-run the extension hook against the fresh cache.
57+
// to re-translate and re-run the extension hook against the fresh cache. On
58+
// delete or targetRef removal it clears the annotation on Gateways that dropped
59+
// out of the policy's target set so EG re-translates and drops the orphaned WAF.
4660
type TPPReconciler struct {
4761
Client client.Client
62+
63+
// mu guards lastTargets. Reconciles for distinct TPPs run concurrently, so
64+
// the previous-target map needs its own lock (controller-runtime only
65+
// serialises reconciles that share a key).
66+
mu sync.Mutex
67+
// lastTargets records the owning Gateway set last stamped for each TPP,
68+
// keyed by namespaced name. It is the source for "which Gateways did this
69+
// TPP used to govern?" on a delete (where the object is gone) or a targetRef
70+
// edit (where only the current targets are otherwise visible). Populated by
71+
// the create event every live TPP fires on startup, so a restart re-learns
72+
// the set before any subsequent edit or delete.
73+
lastTargets map[types.NamespacedName][]string
4874
}
4975

5076
// tppTriggerAnnotationKey returns the per-TPP trigger annotation key.
@@ -53,28 +79,36 @@ func tppTriggerAnnotationKey(tppName string) string {
5379
}
5480

5581
// Reconcile stamps the TPP's observed generation onto the trigger annotation of
56-
// every Gateway it targets. A TPP, the Gateways it targets, and the HTTPProxies
57-
// that generate them share a namespace, and the Gateway is named after the
58-
// targetRef (Gateway targetRefs name the Gateway directly; HTTPRoute targetRefs
59-
// share the HTTPProxy name, which is also the Gateway name), so the
60-
// TPP→Gateway mapping is local and name-derived.
82+
// every Gateway it targets, and clears the annotation on every Gateway that
83+
// dropped out of its target set since the last reconcile (a removed targetRef,
84+
// or — when the TPP itself is gone — all of them). A TPP, the Gateways it
85+
// targets, and the HTTPProxies that generate them share a namespace, and the
86+
// Gateway is named after the targetRef (Gateway targetRefs name the Gateway
87+
// directly; HTTPRoute targetRefs share the HTTPProxy name, which is also the
88+
// Gateway name), so the TPP→Gateway mapping is local and name-derived.
6189
//
62-
// The Gateway patch is a merge patch with no preceding Get: if the generation is
63-
// unchanged the API server treats it as a no-op (no resourceVersion bump, no EG
64-
// event), so it is naturally idempotent and never triggers a spurious
65-
// re-translation.
90+
// Each Gateway patch is a merge patch with no preceding Get: an unchanged
91+
// generation (or clearing an already-absent annotation) is a server-side no-op
92+
// (no resourceVersion bump, no EG event), so it is naturally idempotent and
93+
// never triggers a spurious re-translation.
6694
func (r *TPPReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
6795
logger := log.FromContext(ctx)
96+
annotationKey := tppTriggerAnnotationKey(req.Name)
6897

6998
var tpp networkingv1alpha.TrafficProtectionPolicy
7099
if err := r.Client.Get(ctx, req.NamespacedName, &tpp); err != nil {
71-
return ctrl.Result{}, client.IgnoreNotFound(err)
100+
if client.IgnoreNotFound(err) != nil {
101+
return ctrl.Result{}, err
102+
}
103+
// Deleted: nudge every Gateway the policy used to govern so EG
104+
// re-translates against the now-empty cache and drops the orphaned WAF.
105+
return ctrl.Result{}, r.clearGateways(ctx, logger, req.NamespacedName, annotationKey, r.takeTargets(req.NamespacedName), "delete")
72106
}
73107

74108
value := strconv.FormatInt(tpp.Generation, 10)
75-
annotationKey := tppTriggerAnnotationKey(tpp.Name)
76109

77110
seen := make(map[string]struct{}, len(tpp.Spec.TargetRefs))
111+
current := make([]string, 0, len(tpp.Spec.TargetRefs))
78112
var firstErr error
79113
for _, ref := range tpp.Spec.TargetRefs {
80114
gwName := string(ref.Name)
@@ -85,6 +119,7 @@ func (r *TPPReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.R
85119
continue
86120
}
87121
seen[gwName] = struct{}{}
122+
current = append(current, gwName)
88123

89124
gwKey := client.ObjectKey{Namespace: tpp.Namespace, Name: gwName}
90125
if err := r.touchGateway(ctx, gwKey, annotationKey, value); err != nil {
@@ -99,9 +134,37 @@ func (r *TPPReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.R
99134
"gateway", gwKey, "tpp", tpp.Name, "generation", value)
100135
}
101136

137+
// Clear Gateways that were targeted last time but are not now. On any error
138+
// touching a current target, keep the previous set so the dropped Gateways
139+
// are retried on requeue rather than forgotten.
140+
prev := r.swapTargets(req.NamespacedName, current, firstErr == nil)
141+
if err := r.clearGateways(ctx, logger, req.NamespacedName, annotationKey, removedTargets(prev, seen), "targetRef removal"); err != nil && firstErr == nil {
142+
firstErr = err
143+
}
144+
102145
return ctrl.Result{}, firstErr
103146
}
104147

148+
// clearGateways removes the trigger annotation from each named Gateway in the
149+
// policy's namespace, returning the first error encountered.
150+
func (r *TPPReconciler) clearGateways(ctx context.Context, logger logr.Logger, tpp types.NamespacedName, annotationKey string, gwNames []string, reason string) error {
151+
var firstErr error
152+
for _, gwName := range gwNames {
153+
gwKey := client.ObjectKey{Namespace: tpp.Namespace, Name: gwName}
154+
if err := r.clearGateway(ctx, gwKey, annotationKey); err != nil {
155+
logger.Error(err, "failed to clear gateway trigger for orphaned TPP target",
156+
"gateway", gwKey, "tpp", tpp.Name, "reason", reason)
157+
if firstErr == nil {
158+
firstErr = err
159+
}
160+
continue
161+
}
162+
logger.Info("cleared gateway trigger to drop orphaned WAF config",
163+
"gateway", gwKey, "tpp", tpp.Name, "reason", reason)
164+
}
165+
return firstErr
166+
}
167+
105168
// touchGateway applies a merge patch setting the trigger annotation. A
106169
// non-existent Gateway is ignored: EG translates a Gateway when it is created,
107170
// reading the (already fresh) extension cache, so there is nothing to nudge yet.
@@ -117,9 +180,68 @@ func (r *TPPReconciler) touchGateway(ctx context.Context, key client.ObjectKey,
117180
return nil
118181
}
119182

183+
// clearGateway removes the trigger annotation via a merge patch (JSON null
184+
// deletes the key). Removing the annotation is a Gateway change, so EG
185+
// re-translates; clearing an already-absent key is a server-side no-op.
186+
func (r *TPPReconciler) clearGateway(ctx context.Context, key client.ObjectKey, annotationKey string) error {
187+
gw := &gatewayv1.Gateway{}
188+
gw.Namespace = key.Namespace
189+
gw.Name = key.Name
190+
191+
body := fmt.Appendf(nil, `{"metadata":{"annotations":{%q:null}}}`, annotationKey)
192+
if err := r.Client.Patch(ctx, gw, client.RawPatch(types.MergePatchType, body)); err != nil {
193+
return client.IgnoreNotFound(err)
194+
}
195+
return nil
196+
}
197+
198+
// swapTargets records the TPP's current owning Gateway set and returns the set
199+
// from the previous reconcile. When commit is false the previous set is left in
200+
// place (a touch failed, so the dropped Gateways must stay pending for retry)
201+
// but still returned for diffing.
202+
func (r *TPPReconciler) swapTargets(key types.NamespacedName, current []string, commit bool) []string {
203+
r.mu.Lock()
204+
defer r.mu.Unlock()
205+
prev := r.lastTargets[key]
206+
if !commit {
207+
return prev
208+
}
209+
if r.lastTargets == nil {
210+
r.lastTargets = make(map[types.NamespacedName][]string)
211+
}
212+
if len(current) == 0 {
213+
delete(r.lastTargets, key)
214+
} else {
215+
r.lastTargets[key] = current
216+
}
217+
return prev
218+
}
219+
220+
// takeTargets returns and forgets the TPP's recorded owning Gateway set (used on
221+
// delete, where there is no current set to keep).
222+
func (r *TPPReconciler) takeTargets(key types.NamespacedName) []string {
223+
r.mu.Lock()
224+
defer r.mu.Unlock()
225+
prev := r.lastTargets[key]
226+
delete(r.lastTargets, key)
227+
return prev
228+
}
229+
230+
// removedTargets returns the members of prev not present in current.
231+
func removedTargets(prev []string, current map[string]struct{}) []string {
232+
var removed []string
233+
for _, name := range prev {
234+
if _, ok := current[name]; !ok {
235+
removed = append(removed, name)
236+
}
237+
}
238+
return removed
239+
}
240+
120241
// SetupWithManager registers the controller. It reconciles a TPP only when its
121-
// spec changes (generation bump) — the extension server reads spec fields only
122-
// (mode, targetRefs, ruleSets, sampling), so status/metadata churn is ignored.
242+
// spec changes (generation bump) or it is deleted — the extension server reads
243+
// spec fields only (mode, targetRefs, ruleSets, sampling), so status/metadata
244+
// churn is ignored.
123245
func (r *TPPReconciler) SetupWithManager(mgr ctrl.Manager) error {
124246
return ctrl.NewControllerManagedBy(mgr).
125247
For(&networkingv1alpha.TrafficProtectionPolicy{}, builder.WithPredicates(tppSpecChangedPredicate())).
@@ -128,15 +250,14 @@ func (r *TPPReconciler) SetupWithManager(mgr ctrl.Manager) error {
128250
}
129251

130252
// tppSpecChangedPredicate admits creates (so TPPs already present when the
131-
// controller starts stamp their Gateways once) and updates that bump the
132-
// generation (any spec change: mode flip, targetRef edit, ruleset/sampling
133-
// change). Deletes are not handled here: a deleted TPP leaves no object to read
134-
// targetRefs from, and its Gateways are not re-translated by this arm — see the
135-
// known-limitation note in the PR.
253+
// controller starts stamp their Gateways, and their target set is learned before
254+
// any later edit or delete), updates that bump the generation (any spec change:
255+
// mode flip, targetRef edit, ruleset/sampling change), and deletes (so a removed
256+
// policy's Gateways are re-translated against the now-empty cache).
136257
func tppSpecChangedPredicate() predicate.Predicate {
137258
return predicate.Funcs{
138259
CreateFunc: func(event.CreateEvent) bool { return true },
139-
DeleteFunc: func(event.DeleteEvent) bool { return false },
260+
DeleteFunc: func(event.DeleteEvent) bool { return true },
140261
UpdateFunc: func(e event.UpdateEvent) bool {
141262
oldT, ok1 := e.ObjectOld.(*networkingv1alpha.TrafficProtectionPolicy)
142263
newT, ok2 := e.ObjectNew.(*networkingv1alpha.TrafficProtectionPolicy)

internal/extensionserver/retrigger/tpp_test.go

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,14 @@ func tpp(mode networkingv1alpha.TrafficProtectionPolicyMode, generation int64, g
4747

4848
func reconcileTPP(t *testing.T, cl client.Client) {
4949
t.Helper()
50-
r := &TPPReconciler{Client: cl}
50+
reconcileTPPWith(t, &TPPReconciler{Client: cl})
51+
}
52+
53+
// reconcileTPPWith reconciles through a caller-owned reconciler so its in-memory
54+
// previous-target map survives across reconciles (needed to exercise delete and
55+
// targetRef removal).
56+
func reconcileTPPWith(t *testing.T, r *TPPReconciler) {
57+
t.Helper()
5158
_, err := r.Reconcile(context.Background(), ctrl.Request{
5259
NamespacedName: client.ObjectKey{Namespace: testNS, Name: testTPP},
5360
})
@@ -130,8 +137,58 @@ func TestTPPReconcile_PerTPPKey(t *testing.T) {
130137
"each TPP must own a distinct annotation slot to avoid flip-flop churn")
131138
}
132139

133-
// TestTPPSpecChangedPredicate verifies the controller reconciles on creates and
134-
// generation bumps, and ignores status/metadata churn and deletes.
140+
// TestTPPReconcile_DeletionClearsGateways: after a TPP is stamped onto its
141+
// Gateway, deleting the TPP clears the trigger annotation so EG re-translates
142+
// against the now-empty cache and drops the orphaned WAF program.
143+
func TestTPPReconcile_DeletionClearsGateways(t *testing.T) {
144+
scheme := testScheme(t)
145+
cl := fake.NewClientBuilder().WithScheme(scheme).
146+
WithObjects(tpp(networkingv1alpha.TrafficProtectionPolicyEnforce, 5, testProxy), gateway()).
147+
Build()
148+
r := &TPPReconciler{Client: cl}
149+
150+
reconcileTPPWith(t, r)
151+
require.Equal(t, "5", gatewayTPPTrigger(t, cl, testProxy))
152+
153+
var current networkingv1alpha.TrafficProtectionPolicy
154+
require.NoError(t, cl.Get(context.Background(), client.ObjectKey{Namespace: testNS, Name: testTPP}, &current))
155+
require.NoError(t, cl.Delete(context.Background(), &current))
156+
reconcileTPPWith(t, r)
157+
158+
assert.Empty(t, gatewayTPPTrigger(t, cl, testProxy),
159+
"deleting a TPP must clear its trigger annotation so EG drops the orphaned WAF")
160+
}
161+
162+
// TestTPPReconcile_TargetRefRemovalClearsDropped: dropping a Gateway from
163+
// spec.targetRefs clears that Gateway's trigger annotation while the retained
164+
// target is re-stamped with the new generation.
165+
func TestTPPReconcile_TargetRefRemovalClearsDropped(t *testing.T) {
166+
scheme := testScheme(t)
167+
gwB := &gatewayv1.Gateway{ObjectMeta: metav1.ObjectMeta{Name: "proxy-2", Namespace: testNS}}
168+
cl := fake.NewClientBuilder().WithScheme(scheme).
169+
WithObjects(tpp(networkingv1alpha.TrafficProtectionPolicyEnforce, 2, testProxy, "proxy-2"), gateway(), gwB).
170+
Build()
171+
r := &TPPReconciler{Client: cl}
172+
173+
reconcileTPPWith(t, r)
174+
require.Equal(t, "2", gatewayTPPTrigger(t, cl, testProxy))
175+
require.Equal(t, "2", gatewayTPPTrigger(t, cl, "proxy-2"))
176+
177+
var current networkingv1alpha.TrafficProtectionPolicy
178+
require.NoError(t, cl.Get(context.Background(), client.ObjectKey{Namespace: testNS, Name: testTPP}, &current))
179+
current.Spec.TargetRefs = []gatewayv1alpha2.LocalPolicyTargetReferenceWithSectionName{gatewayTargetRef(testProxy)}
180+
current.Generation = 3
181+
require.NoError(t, cl.Update(context.Background(), &current))
182+
reconcileTPPWith(t, r)
183+
184+
assert.Equal(t, "3", gatewayTPPTrigger(t, cl, testProxy),
185+
"a retained target must be re-stamped with the new generation")
186+
assert.Empty(t, gatewayTPPTrigger(t, cl, "proxy-2"),
187+
"a dropped target must have its trigger annotation cleared")
188+
}
189+
190+
// TestTPPSpecChangedPredicate verifies the controller reconciles on creates,
191+
// generation bumps, and deletes, and ignores status/metadata churn.
135192
func TestTPPSpecChangedPredicate(t *testing.T) {
136193
p := tppSpecChangedPredicate()
137194

@@ -140,8 +197,8 @@ func TestTPPSpecChangedPredicate(t *testing.T) {
140197

141198
assert.True(t, p.Create(event.CreateEvent{Object: observe}),
142199
"create is always admitted so existing TPPs stamp their Gateway on startup")
143-
assert.False(t, p.Delete(event.DeleteEvent{Object: observe}),
144-
"delete is not handled by this arm")
200+
assert.True(t, p.Delete(event.DeleteEvent{Object: observe}),
201+
"delete is admitted so a removed TPP's Gateways are re-translated")
145202
assert.True(t, p.Update(event.UpdateEvent{ObjectOld: observe, ObjectNew: enforce}),
146203
"a generation bump (spec change) must reconcile")
147204

0 commit comments

Comments
 (0)