@@ -3,6 +3,7 @@ package pipeline
33import (
44 "context"
55 "errors"
6+ "fmt"
67 "testing"
78
89 "sigs.k8s.io/controller-runtime/pkg/client"
@@ -16,6 +17,8 @@ import (
1617 "github.com/michelangelo-ai/michelangelo/go/api"
1718 apiHandler "github.com/michelangelo-ai/michelangelo/go/api/handler"
1819 "github.com/michelangelo-ai/michelangelo/go/base/env"
20+ "github.com/michelangelo-ai/michelangelo/go/components/pipelinerun"
21+ "github.com/michelangelo-ai/michelangelo/go/components/triggerrun"
1922 apipb "github.com/michelangelo-ai/michelangelo/proto-go/api"
2023 v2pb "github.com/michelangelo-ai/michelangelo/proto-go/api/v2"
2124 "go.uber.org/zap/zaptest"
@@ -268,11 +271,16 @@ func TestReconcile_RemovesFinalizerOnDelete(t *testing.T) {
268271 }
269272}
270273
271- // updateErroringHandler wraps an api.Handler and returns a configured error
272- // from Update. Used to exercise finalizer Update error branches.
274+ // updateErroringHandler wraps an api.Handler and returns configured errors
275+ // from List/ Update. Used to exercise finalizer and cascade-delete error branches.
273276type updateErroringHandler struct {
274277 api.Handler
275- updateErr error
278+ updateErr error
279+ listErr error
280+ // listErrForType, when non-nil, only fails List when the list object is of
281+ // the given type (e.g. "*v2pb.TriggerRunList"). This lets us assert the
282+ // controller surfaces the exact failure path we expect.
283+ listErrForType string
276284}
277285
278286func (u * updateErroringHandler ) Update (ctx context.Context , obj client.Object , opts * metav1.UpdateOptions ) error {
@@ -282,13 +290,33 @@ func (u *updateErroringHandler) Update(ctx context.Context, obj client.Object, o
282290 return u .Handler .Update (ctx , obj , opts )
283291}
284292
293+ func (u * updateErroringHandler ) List (ctx context.Context , namespace string , opts * metav1.ListOptions , listOptionsExt * apipb.ListOptionsExt , list client.ObjectList ) error {
294+ if u .listErr != nil && (u .listErrForType == "" || u .listErrForType == fmt .Sprintf ("%T" , list )) {
295+ return u .listErr
296+ }
297+ return u .Handler .List (ctx , namespace , opts , listOptionsExt , list )
298+ }
299+
285300func setUpReconcilerWithUpdateErr (t * testing.T , initialObjects []client.Object , updateErr error ) * Reconciler {
301+ return setUpReconcilerWithErrors (t , initialObjects , updateErr , nil , "" )
302+ }
303+
304+ func setUpReconcilerWithErrors (t * testing.T , initialObjects []client.Object , updateErr , listErr error , listErrForType string ) * Reconciler {
286305 scheme := runtime .NewScheme ()
287306 require .NoError (t , v2pb .AddToScheme (scheme ))
288307 k8sClient := fake .NewClientBuilder ().WithScheme (scheme ).WithObjects (initialObjects ... ).WithStatusSubresource (initialObjects ... ).Build ()
308+ logger := zaptest .NewLogger (t )
309+ handler := & updateErroringHandler {
310+ Handler : apiHandler .NewFakeAPIHandler (k8sClient ),
311+ updateErr : updateErr ,
312+ listErr : listErr ,
313+ listErrForType : listErrForType ,
314+ }
289315 return & Reconciler {
290- Handler : & updateErroringHandler {Handler : apiHandler .NewFakeAPIHandler (k8sClient ), updateErr : updateErr },
291- logger : zaptest .NewLogger (t ),
316+ Handler : handler ,
317+ logger : logger ,
318+ triggerRunManager : triggerrun .NewManager (handler , logger ),
319+ pipelineRunManager : pipelinerun .NewManager (handler , logger ),
292320 }
293321}
294322
@@ -313,7 +341,120 @@ func TestReconcile_AddFinalizer_UpdateError(t *testing.T) {
313341 require .Contains (t , err .Error (), "add pipeline finalizer" )
314342}
315343
316- func TestReconcile_RemoveFinalizer_UpdateError (t * testing.T ) {
344+ // NOTE: Error coverage for the delete path is provided by
345+ // TestCascadeDelete_RemoveFinalizer_UpdateError below, which exercises the
346+ // handleDeletion error wrapping introduced in this PR.
347+
348+ func TestCascadeDelete_NoChildren (t * testing.T ) {
349+ now := metav1 .Now ()
350+ pipeline := & v2pb.Pipeline {
351+ ObjectMeta : metav1.ObjectMeta {
352+ Name : "test-pipeline" ,
353+ Namespace : "test-namespace" ,
354+ Finalizers : []string {api .PipelineFinalizer },
355+ DeletionTimestamp : & now ,
356+ },
357+ Spec : v2pb.PipelineSpec {
358+ Commit : & v2pb.CommitInfo {GitRef : "abc123" , Branch : "main" },
359+ },
360+ }
361+ reconciler := setUpReconciler (t , []client.Object {pipeline }, env.Context {})
362+
363+ result , err := reconciler .Reconcile (context .Background (), ctrl.Request {
364+ NamespacedName : types.NamespacedName {Name : "test-pipeline" , Namespace : "test-namespace" },
365+ })
366+ require .NoError (t , err )
367+ require .Equal (t , ctrl.Result {}, result )
368+ }
369+
370+ func TestCascadeDelete_FinalizerAbsent (t * testing.T ) {
371+ // Pipeline with a DeletionTimestamp but not our finalizer must not be
372+ // cascaded. handleDeletion returns immediately without listing children.
373+ // The fake client requires at least one finalizer when DeletionTimestamp
374+ // is set, so we attach an unrelated finalizer.
375+ now := metav1 .Now ()
376+ pipeline := & v2pb.Pipeline {
377+ ObjectMeta : metav1.ObjectMeta {
378+ Name : "test-pipeline" ,
379+ Namespace : "test-namespace" ,
380+ Finalizers : []string {"unrelated/finalizer" },
381+ DeletionTimestamp : & now ,
382+ },
383+ Spec : v2pb.PipelineSpec {
384+ Commit : & v2pb.CommitInfo {GitRef : "abc123" , Branch : "main" },
385+ },
386+ }
387+ // Seed a TR that would normally be killed. handleDeletion must not touch it.
388+ tr := & v2pb.TriggerRun {
389+ ObjectMeta : metav1.ObjectMeta {Name : "tr-running" , Namespace : "test-namespace" },
390+ Spec : v2pb.TriggerRunSpec {
391+ Pipeline : & apipb.ResourceIdentifier {Name : "test-pipeline" , Namespace : "test-namespace" },
392+ },
393+ Status : v2pb.TriggerRunStatus {State : v2pb .TRIGGER_RUN_STATE_RUNNING },
394+ }
395+ reconciler := setUpReconciler (t , []client.Object {pipeline , tr }, env.Context {})
396+
397+ result , err := reconciler .Reconcile (context .Background (), ctrl.Request {
398+ NamespacedName : types.NamespacedName {Name : "test-pipeline" , Namespace : "test-namespace" },
399+ })
400+ require .NoError (t , err )
401+ require .Equal (t , ctrl.Result {}, result )
402+
403+ untouched := & v2pb.TriggerRun {}
404+ require .NoError (t , reconciler .Get (context .Background (), "test-namespace" , "tr-running" , & metav1.GetOptions {}, untouched ))
405+ require .NotEqual (t , v2pb .TRIGGER_RUN_ACTION_KILL , untouched .Spec .Action )
406+ require .False (t , untouched .Spec .Kill )
407+ }
408+
409+ func TestCascadeDelete_ListTriggerRunsError (t * testing.T ) {
410+ now := metav1 .Now ()
411+ pipeline := & v2pb.Pipeline {
412+ ObjectMeta : metav1.ObjectMeta {
413+ Name : "test-pipeline" ,
414+ Namespace : "test-namespace" ,
415+ Finalizers : []string {api .PipelineFinalizer },
416+ DeletionTimestamp : & now ,
417+ },
418+ Spec : v2pb.PipelineSpec {
419+ Commit : & v2pb.CommitInfo {GitRef : "abc123" , Branch : "main" },
420+ },
421+ }
422+ listErr := errors .New ("list tr boom" )
423+ reconciler := setUpReconcilerWithErrors (t , []client.Object {pipeline }, nil , listErr , "*v2.TriggerRunList" )
424+
425+ _ , err := reconciler .Reconcile (context .Background (), ctrl.Request {
426+ NamespacedName : types.NamespacedName {Name : "test-pipeline" , Namespace : "test-namespace" },
427+ })
428+ require .Error (t , err )
429+ require .ErrorIs (t , err , listErr )
430+ require .Contains (t , err .Error (), "list trigger runs for pipeline test-namespace/test-pipeline" )
431+ }
432+
433+ func TestCascadeDelete_ListPipelineRunsError (t * testing.T ) {
434+ now := metav1 .Now ()
435+ pipeline := & v2pb.Pipeline {
436+ ObjectMeta : metav1.ObjectMeta {
437+ Name : "test-pipeline" ,
438+ Namespace : "test-namespace" ,
439+ Finalizers : []string {api .PipelineFinalizer },
440+ DeletionTimestamp : & now ,
441+ },
442+ Spec : v2pb.PipelineSpec {
443+ Commit : & v2pb.CommitInfo {GitRef : "abc123" , Branch : "main" },
444+ },
445+ }
446+ listErr := errors .New ("list pr boom" )
447+ reconciler := setUpReconcilerWithErrors (t , []client.Object {pipeline }, nil , listErr , "*v2.PipelineRunList" )
448+
449+ _ , err := reconciler .Reconcile (context .Background (), ctrl.Request {
450+ NamespacedName : types.NamespacedName {Name : "test-pipeline" , Namespace : "test-namespace" },
451+ })
452+ require .Error (t , err )
453+ require .ErrorIs (t , err , listErr )
454+ require .Contains (t , err .Error (), "list pipeline runs for pipeline test-namespace/test-pipeline" )
455+ }
456+
457+ func TestCascadeDelete_RemoveFinalizer_UpdateError (t * testing.T ) {
317458 now := metav1 .Now ()
318459 pipeline := & v2pb.Pipeline {
319460 ObjectMeta : metav1.ObjectMeta {
@@ -327,24 +468,68 @@ func TestReconcile_RemoveFinalizer_UpdateError(t *testing.T) {
327468 },
328469 }
329470 updateErr := errors .New ("update boom" )
330- reconciler := setUpReconcilerWithUpdateErr (t , []client.Object {pipeline }, updateErr )
471+ reconciler := setUpReconcilerWithErrors (t , []client.Object {pipeline }, updateErr , nil , "" )
331472
332473 _ , err := reconciler .Reconcile (context .Background (), ctrl.Request {
333474 NamespacedName : types.NamespacedName {Name : "test-pipeline" , Namespace : "test-namespace" },
334475 })
335476 require .Error (t , err )
336477 require .ErrorIs (t , err , updateErr )
337- require .Contains (t , err .Error (), "remove pipeline finalizer" )
478+ require .Contains (t , err .Error (), "remove finalizer on pipeline test-namespace/test-pipeline" )
479+ }
480+
481+ func TestCascadeDelete_WithChildrenRequeues (t * testing.T ) {
482+ // When children exist, handleDeletion does not remove the finalizer; it
483+ // requeues after reconcileInterval so a subsequent PR can perform kill/delete.
484+ now := metav1 .Now ()
485+ pipeline := & v2pb.Pipeline {
486+ ObjectMeta : metav1.ObjectMeta {
487+ Name : "test-pipeline" ,
488+ Namespace : "test-namespace" ,
489+ Finalizers : []string {api .PipelineFinalizer },
490+ DeletionTimestamp : & now ,
491+ },
492+ Spec : v2pb.PipelineSpec {
493+ Commit : & v2pb.CommitInfo {GitRef : "abc123" , Branch : "main" },
494+ },
495+ }
496+ tr := & v2pb.TriggerRun {
497+ ObjectMeta : metav1.ObjectMeta {Name : "tr-running" , Namespace : "test-namespace" },
498+ Spec : v2pb.TriggerRunSpec {
499+ Pipeline : & apipb.ResourceIdentifier {Name : "test-pipeline" , Namespace : "test-namespace" },
500+ },
501+ Status : v2pb.TriggerRunStatus {State : v2pb .TRIGGER_RUN_STATE_RUNNING },
502+ }
503+ reconciler := setUpReconciler (t , []client.Object {pipeline , tr }, env.Context {})
504+
505+ result , err := reconciler .Reconcile (context .Background (), ctrl.Request {
506+ NamespacedName : types.NamespacedName {Name : "test-pipeline" , Namespace : "test-namespace" },
507+ })
508+ require .NoError (t , err )
509+ require .Equal (t , ctrl.Result {RequeueAfter : reconcileInterval }, result )
510+
511+ // Finalizer should NOT have been removed yet.
512+ updated := & v2pb.Pipeline {}
513+ require .NoError (t , reconciler .Get (context .Background (), "test-namespace" , "test-pipeline" , & metav1.GetOptions {}, updated ))
514+ require .True (t , controllerutil .ContainsFinalizer (updated , api .PipelineFinalizer ))
338515}
339516
340517func setUpReconciler (t * testing.T , initialObjects []client.Object , env env.Context ) * Reconciler {
341518 scheme := runtime .NewScheme ()
342519 err := v2pb .AddToScheme (scheme )
343520 require .NoError (t , err )
344- k8sClient := fake .NewClientBuilder ().WithScheme (scheme ).WithObjects (initialObjects ... ).WithStatusSubresource (initialObjects ... ).Build ()
521+ k8sClient := fake .NewClientBuilder ().
522+ WithScheme (scheme ).
523+ WithObjects (initialObjects ... ).
524+ WithStatusSubresource (initialObjects ... ).
525+ Build ()
526+ logger := zaptest .NewLogger (t )
527+ handler := apiHandler .NewFakeAPIHandler (k8sClient )
345528 reconciler := & Reconciler {
346- Handler : apiHandler .NewFakeAPIHandler (k8sClient ),
347- logger : zaptest .NewLogger (t ),
529+ Handler : handler ,
530+ logger : logger ,
531+ triggerRunManager : triggerrun .NewManager (handler , logger ),
532+ pipelineRunManager : pipelinerun .NewManager (handler , logger ),
348533 }
349534 return reconciler
350535}
0 commit comments