@@ -2,8 +2,10 @@ package component
22
33import (
44 "context"
5+ "errors"
56 "fmt"
67 "testing"
8+ "time"
79
810 "github.com/stretchr/testify/require"
911 corev1 "k8s.io/api/core/v1"
@@ -174,3 +176,83 @@ func TestEnsureServiceHandler(t *testing.T) {
174176 })
175177 }
176178}
179+
180+ // TestEnsureComponentByHashStopsWhenNewObjSignalsRequeue guards against a panic
181+ // where a newObj factory both calls a queue operation (e.g. RequeueAPIErr) and
182+ // returns nil. RequeueAPIErr cancels the handler context but does not unwind
183+ // the goroutine, so without a ctx.Err() check the framework would dereference
184+ // the nil to attach a hash annotation and segfault. Reproduces the panic
185+ // observed at authzed/internal#10787.
186+ func TestEnsureComponentByHashStopsWhenNewObjSignalsRequeue (t * testing.T ) {
187+ const (
188+ hashKey = "example.com/component-hash"
189+ ownerIndex = "owner"
190+ )
191+
192+ ctx , cancel := context .WithCancel (t .Context ())
193+ defer cancel ()
194+
195+ serviceGVR := corev1 .SchemeGroupVersion .WithResource ("services" )
196+ scheme := runtime .NewScheme ()
197+ require .NoError (t , corev1 .AddToScheme (scheme ))
198+ client := clientfake .NewSimpleDynamicClient (scheme )
199+ informerFactory := dynamicinformer .NewDynamicSharedInformerFactory (client , 0 )
200+ require .NoError (t , informerFactory .ForResource (serviceGVR ).Informer ().AddIndexers (map [string ]cache.IndexFunc {
201+ ownerIndex : func (_ interface {}) ([]string , error ) {
202+ return []string {types.NamespacedName {Namespace : "test" , Name : "owner" }.String ()}, nil
203+ },
204+ }))
205+ informerFactory .Start (ctx .Done ())
206+ informerFactory .WaitForCacheSync (ctx .Done ())
207+ indexer := typed.NewIndexer [* corev1.Service ](informerFactory .ForResource (serviceGVR ).Informer ().GetIndexer ())
208+ ctxOwner := typedctx.WithDefault [types.NamespacedName ](types.NamespacedName {Namespace : "test" , Name : "owner" })
209+
210+ queueOps := queue .NewQueueOperationsCtx ()
211+
212+ // Simulate the manager plumbing: a real Operations whose cancel func
213+ // cancels the handler-scoped context, so RequeueAPIErr propagates via
214+ // ctx.Err() the same way it does in production.
215+ handlerCtx , cancelHandler := context .WithCancel (ctx )
216+ defer cancelHandler ()
217+ ops := queue .NewOperations (func () {}, func (time.Duration ) {}, cancelHandler )
218+ handlerCtx = queueOps .WithValue (handlerCtx , ops )
219+
220+ simulatedErr := errors .New ("simulated upstream failure" )
221+ applyCalled := false
222+ deleteCalled := false
223+
224+ h := handler .NewHandler (NewEnsureComponentByHash (
225+ NewHashableComponent [* corev1.Service ](
226+ NewIndexedComponent (
227+ indexer ,
228+ ownerIndex ,
229+ func (_ context.Context ) labels.Selector {
230+ return labels .SelectorFromSet (map [string ]string {
231+ "example.com/component" : "the-main-service-component" ,
232+ })
233+ }),
234+ hash .NewObjectHash (), hashKey ),
235+ ctxOwner ,
236+ queueOps ,
237+ func (_ context.Context , _ * applycorev1.ServiceApplyConfiguration ) (* corev1.Service , error ) {
238+ applyCalled = true
239+ return nil , nil
240+ },
241+ func (_ context.Context , _ types.NamespacedName ) error {
242+ deleteCalled = true
243+ return nil
244+ },
245+ func (ctx context.Context ) * applycorev1.ServiceApplyConfiguration {
246+ queueOps .RequeueAPIErr (ctx , simulatedErr )
247+ return nil
248+ }), "ensureService" )
249+
250+ require .NotPanics (t , func () {
251+ h .Handle (handlerCtx )
252+ })
253+
254+ require .False (t , applyCalled , "must not apply when newObj signaled requeue" )
255+ require .False (t , deleteCalled , "must not delete when newObj signaled requeue" )
256+ require .Equal (t , simulatedErr , ops .Error (), "Operations must record the requeue error" )
257+ require .Error (t , handlerCtx .Err (), "handler context must be cancelled by RequeueAPIErr" )
258+ }
0 commit comments