Skip to content

Commit edc795e

Browse files
committed
fix: avoid reconciling ended leases
Signed-off-by: Benny Zlotnik <bzlotnik@redhat.com> Assited-by: claude-opus-4.6
1 parent 26aee22 commit edc795e

2 files changed

Lines changed: 185 additions & 1 deletion

File tree

controller/internal/controller/lease_controller.go

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,12 @@ import (
3232
"k8s.io/apimachinery/pkg/runtime"
3333
"k8s.io/apimachinery/pkg/types"
3434
ctrl "sigs.k8s.io/controller-runtime"
35+
"sigs.k8s.io/controller-runtime/pkg/builder"
3536
"sigs.k8s.io/controller-runtime/pkg/client"
3637
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
38+
"sigs.k8s.io/controller-runtime/pkg/event"
3739
"sigs.k8s.io/controller-runtime/pkg/log"
40+
"sigs.k8s.io/controller-runtime/pkg/predicate"
3841
)
3942

4043
// LeaseReconciler reconciles a Lease object
@@ -585,9 +588,37 @@ func filterOutOfflineExporters(approvedExporters []ApprovedExporter) []ApprovedE
585588
return onlineExporters
586589
}
587590

591+
// isLeaseEnded checks whether a lease object carries the ended label.
592+
func isLeaseEnded(obj client.Object) bool {
593+
if v, ok := obj.GetLabels()[string(jumpstarterdevv1alpha1.LeaseLabelEnded)]; ok {
594+
return v == jumpstarterdevv1alpha1.LeaseLabelEndedValue
595+
}
596+
return false
597+
}
598+
599+
// skipEndedPredicate returns a predicate that filters out leases carrying the
600+
// ended label. Leases without the label are admitted so the reconciler can
601+
// backfill it when Status.Ended is true but the label write was lost.
602+
func skipEndedPredicate() predicate.Funcs {
603+
return predicate.Funcs{
604+
CreateFunc: func(e event.CreateEvent) bool {
605+
return !isLeaseEnded(e.Object)
606+
},
607+
UpdateFunc: func(e event.UpdateEvent) bool {
608+
return !isLeaseEnded(e.ObjectNew)
609+
},
610+
DeleteFunc: func(e event.DeleteEvent) bool {
611+
return true
612+
},
613+
GenericFunc: func(e event.GenericEvent) bool {
614+
return !isLeaseEnded(e.Object)
615+
},
616+
}
617+
}
618+
588619
// SetupWithManager sets up the controller with the Manager.
589620
func (r *LeaseReconciler) SetupWithManager(mgr ctrl.Manager) error {
590621
return ctrl.NewControllerManagedBy(mgr).
591-
For(&jumpstarterdevv1alpha1.Lease{}).
622+
For(&jumpstarterdevv1alpha1.Lease{}, builder.WithPredicates(skipEndedPredicate())).
592623
Complete(r)
593624
}

controller/internal/controller/lease_controller_test.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import (
3131
"k8s.io/apimachinery/pkg/api/meta"
3232
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3333
"k8s.io/apimachinery/pkg/types"
34+
"sigs.k8s.io/controller-runtime/pkg/event"
35+
"sigs.k8s.io/controller-runtime/pkg/predicate"
3436
"sigs.k8s.io/controller-runtime/pkg/reconcile"
3537
)
3638

@@ -1027,6 +1029,98 @@ var _ = Describe("orderApprovedExporters", func() {
10271029
})
10281030
})
10291031

1032+
var _ = Describe("isLeaseEnded", func() {
1033+
It("should return true when the ended label is present", func() {
1034+
lease := &jumpstarterdevv1alpha1.Lease{
1035+
ObjectMeta: metav1.ObjectMeta{
1036+
Labels: map[string]string{
1037+
string(jumpstarterdevv1alpha1.LeaseLabelEnded): jumpstarterdevv1alpha1.LeaseLabelEndedValue,
1038+
},
1039+
},
1040+
}
1041+
Expect(isLeaseEnded(lease)).To(BeTrue())
1042+
})
1043+
1044+
It("should return false when the ended label is missing", func() {
1045+
lease := &jumpstarterdevv1alpha1.Lease{
1046+
ObjectMeta: metav1.ObjectMeta{
1047+
Labels: map[string]string{},
1048+
},
1049+
}
1050+
Expect(isLeaseEnded(lease)).To(BeFalse())
1051+
})
1052+
1053+
It("should return false when labels are nil", func() {
1054+
lease := &jumpstarterdevv1alpha1.Lease{}
1055+
Expect(isLeaseEnded(lease)).To(BeFalse())
1056+
})
1057+
})
1058+
1059+
var _ = Describe("skipEndedPredicate", func() {
1060+
var skipEnded predicate.Funcs
1061+
1062+
BeforeEach(func() {
1063+
skipEnded = skipEndedPredicate()
1064+
})
1065+
1066+
It("should admit creates for non-ended leases", func() {
1067+
lease := &jumpstarterdevv1alpha1.Lease{}
1068+
Expect(skipEnded.Create(event.CreateEvent{Object: lease})).To(BeTrue())
1069+
})
1070+
1071+
It("should reject creates for ended leases", func() {
1072+
lease := &jumpstarterdevv1alpha1.Lease{
1073+
ObjectMeta: metav1.ObjectMeta{
1074+
Labels: map[string]string{
1075+
string(jumpstarterdevv1alpha1.LeaseLabelEnded): jumpstarterdevv1alpha1.LeaseLabelEndedValue,
1076+
},
1077+
},
1078+
}
1079+
Expect(skipEnded.Create(event.CreateEvent{Object: lease})).To(BeFalse())
1080+
})
1081+
1082+
It("should reject updates where new object has ended label", func() {
1083+
oldLease := &jumpstarterdevv1alpha1.Lease{}
1084+
newLease := &jumpstarterdevv1alpha1.Lease{
1085+
ObjectMeta: metav1.ObjectMeta{
1086+
Labels: map[string]string{
1087+
string(jumpstarterdevv1alpha1.LeaseLabelEnded): jumpstarterdevv1alpha1.LeaseLabelEndedValue,
1088+
},
1089+
},
1090+
}
1091+
Expect(skipEnded.Update(event.UpdateEvent{ObjectOld: oldLease, ObjectNew: newLease})).To(BeFalse())
1092+
})
1093+
1094+
It("should admit updates for non-ended leases", func() {
1095+
oldLease := &jumpstarterdevv1alpha1.Lease{}
1096+
newLease := &jumpstarterdevv1alpha1.Lease{}
1097+
Expect(skipEnded.Update(event.UpdateEvent{ObjectOld: oldLease, ObjectNew: newLease})).To(BeTrue())
1098+
})
1099+
1100+
It("should admit updates for ended-in-status but unlabeled leases so label can be backfilled", func() {
1101+
oldLease := &jumpstarterdevv1alpha1.Lease{}
1102+
newLease := &jumpstarterdevv1alpha1.Lease{
1103+
Status: jumpstarterdevv1alpha1.LeaseStatus{
1104+
Ended: true,
1105+
},
1106+
}
1107+
// No ended label → predicate admits → reconciler runs → backfills label
1108+
Expect(skipEnded.Update(event.UpdateEvent{ObjectOld: oldLease, ObjectNew: newLease})).To(BeTrue())
1109+
})
1110+
1111+
It("should always admit deletes", func() {
1112+
lease := &jumpstarterdevv1alpha1.Lease{
1113+
ObjectMeta: metav1.ObjectMeta{
1114+
Labels: map[string]string{
1115+
string(jumpstarterdevv1alpha1.LeaseLabelEnded): jumpstarterdevv1alpha1.LeaseLabelEndedValue,
1116+
},
1117+
},
1118+
}
1119+
Expect(skipEnded.Delete(event.DeleteEvent{Object: lease})).To(BeTrue())
1120+
})
1121+
1122+
})
1123+
10301124
var _ = Describe("Scheduled Leases", func() {
10311125
BeforeEach(func() {
10321126
createExporters(context.Background(), testExporter1DutA, testExporter2DutA, testExporter3DutB)
@@ -1709,6 +1803,65 @@ var _ = Describe("Scheduled Leases", func() {
17091803
})
17101804
})
17111805

1806+
When("an ended lease has the ended label", func() {
1807+
It("should be a no-op on subsequent reconciles", func() {
1808+
lease := leaseDutA2Sec.DeepCopy()
1809+
lease.Spec.Duration = &metav1.Duration{Duration: 100 * time.Millisecond}
1810+
1811+
ctx := context.Background()
1812+
Expect(k8sClient.Create(ctx, lease)).To(Succeed())
1813+
_ = reconcileLease(ctx, lease)
1814+
1815+
Eventually(func() bool {
1816+
_ = reconcileLease(ctx, lease)
1817+
return getLease(ctx, lease.Name).Status.Ended
1818+
}).WithTimeout(500 * time.Millisecond).WithPolling(50 * time.Millisecond).Should(BeTrue())
1819+
1820+
updatedLease := getLease(ctx, lease.Name)
1821+
Expect(updatedLease.Labels).To(HaveKeyWithValue(
1822+
string(jumpstarterdevv1alpha1.LeaseLabelEnded),
1823+
jumpstarterdevv1alpha1.LeaseLabelEndedValue,
1824+
))
1825+
1826+
// Reconciling again should be a no-op
1827+
result := reconcileLease(ctx, lease)
1828+
Expect(result.RequeueAfter).To(BeZero())
1829+
})
1830+
})
1831+
1832+
When("an ended lease is missing the ended label", func() {
1833+
It("should backfill the label on the next reconcile", func() {
1834+
lease := leaseDutA2Sec.DeepCopy()
1835+
lease.Spec.Duration = &metav1.Duration{Duration: 100 * time.Millisecond}
1836+
1837+
ctx := context.Background()
1838+
Expect(k8sClient.Create(ctx, lease)).To(Succeed())
1839+
_ = reconcileLease(ctx, lease)
1840+
1841+
Eventually(func() bool {
1842+
_ = reconcileLease(ctx, lease)
1843+
return getLease(ctx, lease.Name).Status.Ended
1844+
}).WithTimeout(500 * time.Millisecond).WithPolling(50 * time.Millisecond).Should(BeTrue())
1845+
1846+
// Simulate split-brain: remove the ended label as if metadata update failed
1847+
updatedLease := getLease(ctx, lease.Name)
1848+
delete(updatedLease.Labels, string(jumpstarterdevv1alpha1.LeaseLabelEnded))
1849+
Expect(k8sClient.Update(ctx, updatedLease)).To(Succeed())
1850+
1851+
// Verify label is gone
1852+
updatedLease = getLease(ctx, lease.Name)
1853+
Expect(updatedLease.Labels).NotTo(HaveKey(string(jumpstarterdevv1alpha1.LeaseLabelEnded)))
1854+
1855+
// Reconcile should backfill the label
1856+
_ = reconcileLease(ctx, lease)
1857+
updatedLease = getLease(ctx, lease.Name)
1858+
Expect(updatedLease.Labels).To(HaveKeyWithValue(
1859+
string(jumpstarterdevv1alpha1.LeaseLabelEnded),
1860+
jumpstarterdevv1alpha1.LeaseLabelEndedValue,
1861+
))
1862+
})
1863+
})
1864+
17121865
// UpdateLease mutation tests
17131866
// Note: These tests simulate what UpdateLease does via gRPC by directly
17141867
// modifying the lease spec and calling ReconcileLeaseTimeFields

0 commit comments

Comments
 (0)