@@ -613,7 +613,7 @@ func TestReconcileQuota(t *testing.T) {
613613 return r , projectClient , quotaClient
614614 }
615615
616- t .Run ("quota granted flow: claim granted removes gate and sets QuotaGranted=True" , func (t * testing.T ) {
616+ t .Run ("quota granted flow: claim granted removes gate and sets QuotaGranted=True in single reconcile " , func (t * testing.T ) {
617617 s := newTestScheme (t )
618618 instance := makeInstance (s ,
619619 computev1alpha.SchedulingGate {Name : instancecontrol .NetworkSchedulingGate .String ()},
@@ -623,7 +623,10 @@ func TestReconcileQuota(t *testing.T) {
623623
624624 r , projectClient , _ := newReconciler (t , []client.Object {instance , makeDeployment ()}, []client.Object {claim })
625625
626- // First reconcile: sets QuotaGranted=True in status, returns early.
626+ // Single reconcile: sets QuotaGranted=True in status AND removes the
627+ // Quota scheduling gate in the same pass. The early-return-before-gate-
628+ // removal bug required a second reconcile that never arrived because
629+ // ResourceClaims are immutable and local Instances are not watched.
627630 _ , err := r .Reconcile (context .Background (), mcreconcile.Request {Request : reconcile.Request {NamespacedName : types.NamespacedName {Namespace : namespace , Name : instanceName }}, ClusterName : clusterName })
628631 require .NoError (t , err )
629632
@@ -635,19 +638,13 @@ func TestReconcileQuota(t *testing.T) {
635638 assert .Equal (t , metav1 .ConditionTrue , quotaCond .Status )
636639 assert .Equal (t , computev1alpha .InstanceQuotaGrantedReasonQuotaAvailable , quotaCond .Reason )
637640
638- // Second reconcile: status is already set, so removes the scheduling gate.
639- _ , err = r .Reconcile (context .Background (), mcreconcile.Request {Request : reconcile.Request {NamespacedName : types.NamespacedName {Namespace : namespace , Name : instanceName }}, ClusterName : clusterName })
640- require .NoError (t , err )
641-
642- require .NoError (t , projectClient .Get (context .Background (), types.NamespacedName {Namespace : namespace , Name : instanceName }, & updated ))
643-
644641 hasQuotaGate := false
645642 for _ , g := range updated .Spec .Controller .SchedulingGates {
646643 if g .Name == instancecontrol .QuotaSchedulingGate .String () {
647644 hasQuotaGate = true
648645 }
649646 }
650- assert .False (t , hasQuotaGate , "QuotaSchedulingGate should have been removed" )
647+ assert .False (t , hasQuotaGate , "QuotaSchedulingGate must be removed in the same reconcile pass as the status update " )
651648 })
652649
653650 t .Run ("quota exceeded flow: conditions cascade to block Programmed/Running/Ready" , func (t * testing.T ) {
@@ -721,7 +718,9 @@ func TestReconcileQuota(t *testing.T) {
721718 }
722719 require .NoError (t , mgmtClient .Status ().Update (context .Background (), & existingClaim ))
723720
724- // Second reconcile should see granted claim and update status.
721+ // Second reconcile should see the granted claim, update status to
722+ // QuotaGranted=True, AND remove the gate in the same pass (no third
723+ // reconcile required).
725724 _ , err = r .Reconcile (context .Background (), mcreconcile.Request {Request : reconcile.Request {NamespacedName : types.NamespacedName {Namespace : namespace , Name : instanceName }}, ClusterName : clusterName })
726725 require .NoError (t , err )
727726
@@ -731,18 +730,13 @@ func TestReconcileQuota(t *testing.T) {
731730 require .NotNil (t , quotaCond )
732731 assert .Equal (t , metav1 .ConditionTrue , quotaCond .Status )
733732
734- // Third reconcile removes the gate (status is already true, no more status write needed).
735- _ , err = r .Reconcile (context .Background (), mcreconcile.Request {Request : reconcile.Request {NamespacedName : types.NamespacedName {Namespace : namespace , Name : instanceName }}, ClusterName : clusterName })
736- require .NoError (t , err )
737-
738- require .NoError (t , projectClient .Get (context .Background (), types.NamespacedName {Namespace : namespace , Name : instanceName }, & recovered ))
739733 hasQuotaGate := false
740734 for _ , g := range recovered .Spec .Controller .SchedulingGates {
741735 if g .Name == instancecontrol .QuotaSchedulingGate .String () {
742736 hasQuotaGate = true
743737 }
744738 }
745- assert .False (t , hasQuotaGate , "QuotaSchedulingGate should have been removed after quota granted " )
739+ assert .False (t , hasQuotaGate , "QuotaSchedulingGate should be removed in the same reconcile pass that sets QuotaGranted=True " )
746740 })
747741
748742 t .Run ("deleted before grant: finalizer deletes claim and is removed" , func (t * testing.T ) {
@@ -797,6 +791,194 @@ func TestReconcileQuota(t *testing.T) {
797791 })
798792}
799793
794+ // TestQuotaGateRemovedInSingleReconcile is a regression test for the bug where
795+ // the Quota scheduling gate was never removed from an Instance after quota was
796+ // granted. The root cause was an early return in the Reconcile function: when
797+ // reconcileQuotaCondition set QuotaGranted=True (statusChanged=true), the code
798+ // wrote the status update and returned before reaching removeQuotaSchedulingGate.
799+ // Because ResourceClaims are immutable (no further transitions) and local
800+ // Instances are not watched (WithEngageWithLocalCluster(false)), no requeue ever
801+ // arrived — leaving the Quota gate stranded in spec.controller.schedulingGates
802+ // and the projected Instance stuck "Pending (SchedulingGatesPresent)".
803+ //
804+ // The fix: on the success path (quotaErr==nil), fall through to
805+ // removeQuotaSchedulingGate after persisting the status update, so gate removal
806+ // happens in the same reconcile pass as the QuotaGranted=True status write.
807+ func TestQuotaGateRemovedInSingleReconcile (t * testing.T ) {
808+ const (
809+ clusterName = "test-project"
810+ namespace = "default"
811+ instanceName = "my-instance"
812+ deploymentName = "my-deployment"
813+ )
814+
815+ claimName := namespace + "--" + instanceName
816+
817+ tests := []struct {
818+ name string
819+ initialGates []computev1alpha.SchedulingGate
820+ expectGateGone bool
821+ }{
822+ {
823+ name : "Quota gate only: removed in single reconcile when claim is granted" ,
824+ initialGates : []computev1alpha.SchedulingGate {
825+ {Name : instancecontrol .QuotaSchedulingGate .String ()},
826+ },
827+ expectGateGone : true ,
828+ },
829+ {
830+ name : "Quota gate plus Network gate: Quota removed, Network preserved" ,
831+ initialGates : []computev1alpha.SchedulingGate {
832+ {Name : instancecontrol .NetworkSchedulingGate .String ()},
833+ {Name : instancecontrol .QuotaSchedulingGate .String ()},
834+ },
835+ expectGateGone : true ,
836+ },
837+ {
838+ name : "No gates: no-op, reconcile completes cleanly" ,
839+ initialGates : []computev1alpha.SchedulingGate {},
840+ expectGateGone : false , // no gate to begin with
841+ },
842+ }
843+
844+ for _ , tt := range tests {
845+ t .Run (tt .name , func (t * testing.T ) {
846+ s := newTestScheme (t )
847+
848+ instance := & computev1alpha.Instance {
849+ ObjectMeta : metav1.ObjectMeta {
850+ Name : instanceName ,
851+ Namespace : namespace ,
852+ Generation : 1 ,
853+ Finalizers : []string {instanceQuotaFinalizer , instanceControllerFinalizer },
854+ OwnerReferences : []metav1.OwnerReference {
855+ {
856+ APIVersion : testComputeAPIVersion ,
857+ Kind : kindWorkloadDeploymentTest ,
858+ Name : deploymentName ,
859+ UID : testUIDString ,
860+ Controller : func () * bool { b := true ; return & b }(),
861+ },
862+ },
863+ },
864+ Spec : computev1alpha.InstanceSpec {
865+ Controller : & computev1alpha.InstanceController {
866+ SchedulingGates : tt .initialGates ,
867+ },
868+ Runtime : computev1alpha.InstanceRuntimeSpec {
869+ Resources : computev1alpha.InstanceRuntimeResources {InstanceType : testInstanceType },
870+ },
871+ NetworkInterfaces : []computev1alpha.InstanceNetworkInterface {},
872+ },
873+ }
874+
875+ deployment := & computev1alpha.WorkloadDeployment {
876+ ObjectMeta : metav1.ObjectMeta {Name : deploymentName , Namespace : namespace , UID : testUIDString },
877+ }
878+
879+ // ResourceClaim already in QuotaAvailable state — simulates the state
880+ // that triggered the bug: claim already granted but gate still present.
881+ claim := & quotav1alpha1.ResourceClaim {
882+ ObjectMeta : metav1.ObjectMeta {Name : claimName , Namespace : namespace },
883+ Spec : quotav1alpha1.ResourceClaimSpec {
884+ ConsumerRef : quotav1alpha1.ConsumerRef {
885+ APIGroup : miloProjectAPIGroup , Kind : miloProjectKind , Name : clusterName ,
886+ },
887+ ResourceRef : quotav1alpha1.UnversionedObjectReference {
888+ APIGroup : miloProjectAPIGroup , Kind : miloProjectKind , Name : clusterName ,
889+ },
890+ Requests : []quotav1alpha1.ResourceRequest {
891+ {ResourceType : quotaResourceTypeInstances , Amount : 1 },
892+ },
893+ },
894+ Status : quotav1alpha1.ResourceClaimStatus {
895+ Conditions : []metav1.Condition {
896+ {
897+ Type : quotav1alpha1 .ResourceClaimGranted ,
898+ Status : metav1 .ConditionTrue ,
899+ Reason : quotav1alpha1 .ResourceClaimGrantedReason ,
900+ Message : "quota available" ,
901+ LastTransitionTime : metav1 .Now (),
902+ },
903+ },
904+ },
905+ }
906+
907+ projectClient := fake .NewClientBuilder ().
908+ WithScheme (s ).
909+ WithObjects (instance , deployment ).
910+ WithStatusSubresource (& computev1alpha.Instance {}).
911+ Build ()
912+
913+ quotaClient := fake .NewClientBuilder ().
914+ WithScheme (s ).
915+ WithObjects (claim ).
916+ WithStatusSubresource (& quotav1alpha1.ResourceClaim {}).
917+ Build ()
918+
919+ mgr := & fakeMCManager {
920+ clusters : map [string ]cluster.Cluster {
921+ clusterName : newFakeCluster (projectClient ),
922+ },
923+ }
924+
925+ qm := quota .New (nil )
926+ qm .StoreClient (clusterName , quotaClient )
927+
928+ r := & InstanceReconciler {
929+ mgr : mgr ,
930+ scheme : s ,
931+ quotaClientManager : qm ,
932+ edgeClusterName : testEdgeClusterName ,
933+ projectIDForInstance : func (_ context.Context , cn multicluster.ClusterName , _ * computev1alpha.Instance ) (string , error ) {
934+ return string (cn ), nil
935+ },
936+ }
937+ r .finalizers = finalizer .NewFinalizers ()
938+ require .NoError (t , r .finalizers .Register (instanceControllerFinalizer , r ))
939+
940+ // Exactly one reconcile — must be sufficient to both set QuotaGranted=True
941+ // and remove the Quota gate. No second reconcile should be required.
942+ _ , err := r .Reconcile (context .Background (), mcreconcile.Request {
943+ Request : reconcile.Request {NamespacedName : types.NamespacedName {Namespace : namespace , Name : instanceName }},
944+ ClusterName : clusterName ,
945+ })
946+ require .NoError (t , err )
947+
948+ var updated computev1alpha.Instance
949+ require .NoError (t , projectClient .Get (context .Background (),
950+ types.NamespacedName {Namespace : namespace , Name : instanceName }, & updated ))
951+
952+ // QuotaGranted condition must be set to True.
953+ quotaCond := apimeta .FindStatusCondition (updated .Status .Conditions , computev1alpha .InstanceQuotaGranted )
954+ require .NotNil (t , quotaCond , "QuotaGranted condition must be present" )
955+ assert .Equal (t , metav1 .ConditionTrue , quotaCond .Status )
956+ assert .Equal (t , computev1alpha .InstanceQuotaGrantedReasonQuotaAvailable , quotaCond .Reason )
957+
958+ // Quota gate must be gone after the single reconcile.
959+ hasQuotaGate := false
960+ for _ , g := range updated .Spec .Controller .SchedulingGates {
961+ if g .Name == instancecontrol .QuotaSchedulingGate .String () {
962+ hasQuotaGate = true
963+ }
964+ }
965+ if tt .expectGateGone {
966+ assert .False (t , hasQuotaGate ,
967+ "Quota gate must be removed in the same reconcile pass as the QuotaGranted=True status write; " +
968+ "a stranded gate leaves the projected Instance stuck Pending (SchedulingGatesPresent)" )
969+ }
970+
971+ // Network gate (if present) must be preserved — only the Quota gate is
972+ // cleared by InstanceReconciler; NetworkSchedulingGate is owned by
973+ // WorkloadDeploymentReconciler.
974+ for _ , g := range updated .Spec .Controller .SchedulingGates {
975+ assert .NotEqual (t , instancecontrol .QuotaSchedulingGate .String (), g .Name ,
976+ "Quota gate must not remain after granted claim" )
977+ }
978+ })
979+ }
980+ }
981+
800982// TestReconcileQuotaSingleMode verifies that in single-cell mode:
801983// - the project ID is decoded from the upstream-cluster-name label on the edge
802984// namespace (not taken from the always-"single" ClusterName)
@@ -1364,32 +1546,24 @@ func TestReconcileQuotaFailureModes(t *testing.T) {
13641546 r .finalizers = finalizer .NewFinalizers ()
13651547 require .NoError (t , r .finalizers .Register (instanceControllerFinalizer , r ))
13661548
1367- // First reconcile: the live claim is True, but reconcileQuotaCondition will
1368- // write a new QuotaGranted=True with ObservedGeneration=2. Status update
1369- // triggers early return.
1549+ // Single reconcile: reconcileQuotaCondition writes QuotaGranted=True with
1550+ // ObservedGeneration=2 into the in-memory instance, status is persisted,
1551+ // then removeQuotaSchedulingGate reads the in-memory condition (gen=2 ==
1552+ // instance.Generation=2) and removes the gate — all in one pass.
13701553 _ , err := r .Reconcile (context .Background (), reconcileReq ())
13711554 require .NoError (t , err )
13721555
1373- // The gate must NOT have been removed yet — the stale True (gen=1) should
1374- // have been replaced with updated True (gen=2) in the first reconcile, but
1375- // removeQuotaSchedulingGate is only called after status is written and the
1376- // function returns early after the status update.
1377- // Second reconcile: now status has QuotaGranted=True/gen=2 → gate is removed.
1378- _ , err = r .Reconcile (context .Background (), reconcileReq ())
1379- require .NoError (t , err )
1380-
13811556 var updated computev1alpha.Instance
13821557 require .NoError (t , projectClient .Get (context .Background (),
13831558 types.NamespacedName {Namespace : testNS , Name : testInstance }, & updated ))
13841559
1385- // After two reconciles with a live granted claim, gate should be removed.
13861560 hasGate := false
13871561 for _ , g := range updated .Spec .Controller .SchedulingGates {
13881562 if g .Name == instancecontrol .QuotaSchedulingGate .String () {
13891563 hasGate = true
13901564 }
13911565 }
1392- assert .False (t , hasGate , "gate should be removed after quota confirmed for current generation" )
1566+ assert .False (t , hasGate , "gate should be removed in the same reconcile that refreshes the condition to current generation" )
13931567
13941568 cond := apimeta .FindStatusCondition (updated .Status .Conditions , computev1alpha .InstanceQuotaGranted )
13951569 require .NotNil (t , cond )
0 commit comments