Skip to content

Commit 7a9da7c

Browse files
scotwellsclaude
andcommitted
fix: quota-vs-referenced-data priority when both gates present
When QuotaGranted=False and scheduling gates are present, the previous code early-returned Ready=False/PendingQuota before the evaluate-all-then- pick block could run. This meant SourceNotFound (priority 5) was masked by PendingQuota (priority 3) — the same class of short-circuit bug the evaluate-all redesign was meant to eliminate. Fix: when scheduling gates are present, quota is fed into consider() like any other blocking cause so instanceBlockingReasonPriority picks the winner. The Programmed=False and Running=False side effects of quota denial are preserved unconditionally regardless of which reason wins Ready — they reflect quota state independently. The quota early-return is retained only for the no-gates case, where quota is the sole active blocker and the three-condition atomic write is correct. The scheduling-gates evaluation block is extracted into reconcileGatedReadyCondition to keep reconcileInstanceReadyCondition within the project's cyclomatic-complexity lint limit (gocyclo ≤ 30). Adds TestReconcileInstanceReadyCondition_QuotaVsReferencedData (RFC §8.1 headline case): QuotaGranted=False/QuotaExceeded + ReferencedDataReady= False/SourceNotFound → Ready=False/SourceNotFound (priority 5 > 3), with Programmed=False/Running=False still set. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> (cherry picked from commit e4d419b)
1 parent 3cd20b2 commit 7a9da7c

2 files changed

Lines changed: 196 additions & 53 deletions

File tree

internal/controller/instance_controller.go

Lines changed: 114 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,103 @@ func (r *InstanceReconciler) setReferencedDataConditionWithTransition(
635635
return changed
636636
}
637637

638+
// reconcileGatedReadyCondition handles the scheduling-gates branch of
639+
// reconcileInstanceReadyCondition. It evaluates ALL blocking sub-conditions
640+
// (quota, referenced-data, network failure) via the priority function and sets
641+
// Instance.Ready to the highest-priority cause.
642+
//
643+
// When quota is denied, Programmed=False and Running=False are also set
644+
// regardless of which reason wins Ready — quota gates programmatic activity
645+
// independently of the Ready reason displayed to the user.
646+
//
647+
// This is extracted from reconcileInstanceReadyCondition to keep that function's
648+
// cyclomatic complexity within the project lint limit.
649+
func (r *InstanceReconciler) reconcileGatedReadyCondition(
650+
ctx context.Context,
651+
clusterClient client.Client,
652+
instance *computev1alpha.Instance,
653+
quotaDenied bool,
654+
quotaGrantedCondition *metav1.Condition,
655+
readyCondition *metav1.Condition,
656+
checker networkFailureChecker,
657+
) (changed bool, err error) {
658+
schedulingGateNames := make([]string, 0, len(instance.Spec.Controller.SchedulingGates))
659+
for _, gate := range instance.Spec.Controller.SchedulingGates {
660+
schedulingGateNames = append(schedulingGateNames, gate.Name)
661+
}
662+
663+
type candidate struct {
664+
status metav1.ConditionStatus
665+
reason string
666+
message string
667+
priority int
668+
}
669+
670+
// Start with the generic fallback so there is always a winner.
671+
best := candidate{
672+
status: metav1.ConditionFalse,
673+
reason: computev1alpha.InstanceReadyReasonSchedulingGatesPresent,
674+
message: fmt.Sprintf("Scheduling gates present: %s", strings.Join(schedulingGateNames, ", ")),
675+
priority: 0,
676+
}
677+
678+
consider := func(status metav1.ConditionStatus, reason, message string) {
679+
p := instanceBlockingReasonPriority(reason)
680+
if p > best.priority {
681+
best = candidate{status: status, reason: reason, message: message, priority: p}
682+
}
683+
}
684+
685+
// Quota is a gate-level blocker: feed it through the priority function so it
686+
// competes fairly with other causes (priority 3). A co-occurring SourceNotFound
687+
// (priority 5) will correctly beat it.
688+
if quotaDenied {
689+
consider(metav1.ConditionFalse, computev1alpha.InstanceProgrammedReasonPendingQuota, quotaGrantedCondition.Message)
690+
}
691+
692+
// Check the ReferencedDataReady sub-condition set earlier in this reconcile.
693+
if refDataCond := apimeta.FindStatusCondition(instance.Status.Conditions, computev1alpha.ReferencedDataReady); refDataCond != nil && refDataCond.Status != metav1.ConditionTrue {
694+
consider(refDataCond.Status, refDataCond.Reason, refDataCond.Message)
695+
}
696+
697+
// Network creation failure is a hard error; call the checker unconditionally
698+
// so priority logic can compare it against other blocking causes.
699+
networkCreationFailure, networkCreationFailureMessage, err := checker(ctx, clusterClient, instance)
700+
if err != nil {
701+
return false, fmt.Errorf("failed checking for network creation failure: %w", err)
702+
}
703+
if networkCreationFailure {
704+
consider(metav1.ConditionFalse, reasonNetworkFailedToCreate, networkCreationFailureMessage)
705+
}
706+
707+
// When quota is denied, always stamp Programmed=False and Available=False
708+
// regardless of which reason wins Ready. These reflect quota state independently
709+
// of the Ready reason selection.
710+
if quotaDenied {
711+
msg := quotaGrantedCondition.Message
712+
changed = apimeta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{
713+
Type: computev1alpha.InstanceProgrammed,
714+
Status: metav1.ConditionFalse,
715+
Reason: computev1alpha.InstanceProgrammedReasonPendingQuota,
716+
Message: msg,
717+
ObservedGeneration: instance.Generation,
718+
})
719+
changed = apimeta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{
720+
Type: computev1alpha.InstanceAvailable,
721+
Status: metav1.ConditionFalse,
722+
Reason: computev1alpha.InstanceProgrammedReasonPendingQuota,
723+
Message: msg,
724+
ObservedGeneration: instance.Generation,
725+
}) || changed
726+
}
727+
728+
readyCondition.Status = best.status
729+
readyCondition.Reason = best.reason
730+
readyCondition.Message = best.message
731+
732+
return apimeta.SetStatusCondition(&instance.Status.Conditions, *readyCondition) || changed, nil
733+
}
734+
638735
// isTerminalReferencedDataReason reports whether the given ReferencedData reason
639736
// is terminal — i.e., the companion will never arrive because the source object
640737
// is permanently unavailable, not just slow to propagate.
@@ -1464,7 +1561,21 @@ func (r *InstanceReconciler) reconcileInstanceReadyCondition(
14641561
logger := log.FromContext(ctx)
14651562

14661563
quotaGrantedCondition := apimeta.FindStatusCondition(instance.Status.Conditions, computev1alpha.InstanceQuotaGranted)
1467-
if quotaGrantedCondition != nil && quotaGrantedCondition.Status == metav1.ConditionFalse {
1564+
quotaDenied := quotaGrantedCondition != nil && quotaGrantedCondition.Status == metav1.ConditionFalse
1565+
1566+
// When scheduling gates are present, all blocking causes — including quota —
1567+
// are evaluated together so the priority function picks the most actionable
1568+
// one to surface on Ready. Quota side effects (Programmed=False, Running=False)
1569+
// are preserved unconditionally when quota is denied, regardless of which
1570+
// reason wins Ready.
1571+
//
1572+
// When no gates are present and quota is denied, fall through to the early
1573+
// return below which sets Ready, Programmed, and Running atomically.
1574+
hasSchedulingGates := instance.Spec.Controller != nil && len(instance.Spec.Controller.SchedulingGates) > 0
1575+
1576+
if quotaDenied && !hasSchedulingGates {
1577+
// No gates: quota is the only active blocking cause. Set all three
1578+
// conditions atomically and return — same behavior as before.
14681579
msg := quotaGrantedCondition.Message
14691580
changed = apimeta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{
14701581
Type: computev1alpha.InstanceProgrammed,
@@ -1503,58 +1614,8 @@ func (r *InstanceReconciler) reconcileInstanceReadyCondition(
15031614
readyCondition = readyCondition.DeepCopy()
15041615
}
15051616

1506-
if instance.Spec.Controller != nil && len(instance.Spec.Controller.SchedulingGates) > 0 {
1507-
var schedulingGateNames []string
1508-
for _, gate := range instance.Spec.Controller.SchedulingGates {
1509-
schedulingGateNames = append(schedulingGateNames, gate.Name)
1510-
}
1511-
1512-
// Evaluate ALL blocking sub-conditions before choosing which to surface.
1513-
// A short-circuit on the first match would let a low-priority transient
1514-
// cause (e.g. network provisioning) mask a high-priority actionable error
1515-
// (e.g. a missing source ConfigMap).
1516-
type candidate struct {
1517-
status metav1.ConditionStatus
1518-
reason string
1519-
message string
1520-
priority int
1521-
}
1522-
1523-
// Start with the generic fallback so there is always a winner.
1524-
best := candidate{
1525-
status: metav1.ConditionFalse,
1526-
reason: computev1alpha.InstanceReadyReasonSchedulingGatesPresent,
1527-
message: fmt.Sprintf("Scheduling gates present: %s", strings.Join(schedulingGateNames, ", ")),
1528-
priority: 0,
1529-
}
1530-
1531-
consider := func(status metav1.ConditionStatus, reason, message string) {
1532-
p := instanceBlockingReasonPriority(reason)
1533-
if p > best.priority {
1534-
best = candidate{status: status, reason: reason, message: message, priority: p}
1535-
}
1536-
}
1537-
1538-
// Check the ReferencedDataReady sub-condition set earlier in this reconcile.
1539-
if refDataCond := apimeta.FindStatusCondition(instance.Status.Conditions, computev1alpha.ReferencedDataReady); refDataCond != nil && refDataCond.Status != metav1.ConditionTrue {
1540-
consider(refDataCond.Status, refDataCond.Reason, refDataCond.Message)
1541-
}
1542-
1543-
// Network creation failure is a hard error; call the checker unconditionally
1544-
// so priority logic can compare it against other blocking causes.
1545-
networkCreationFailure, networkCreationFailureMessage, err := networkFailureChecker(ctx, clusterClient, instance)
1546-
if err != nil {
1547-
return false, fmt.Errorf("failed checking for network creation failure: %w", err)
1548-
}
1549-
if networkCreationFailure {
1550-
consider(metav1.ConditionFalse, reasonNetworkFailedToCreate, networkCreationFailureMessage)
1551-
}
1552-
1553-
readyCondition.Status = best.status
1554-
readyCondition.Reason = best.reason
1555-
readyCondition.Message = best.message
1556-
1557-
return apimeta.SetStatusCondition(&instance.Status.Conditions, *readyCondition), nil
1617+
if hasSchedulingGates {
1618+
return r.reconcileGatedReadyCondition(ctx, clusterClient, instance, quotaDenied, quotaGrantedCondition, readyCondition, networkFailureChecker)
15581619
}
15591620

15601621
pendingReason := "Pending"

internal/controller/instance_controller_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ const (
5858
// testMsgNetworkCreationFailed is the network-failure message used by the
5959
// evaluate-all-then-pick blocking-reason tests.
6060
testMsgNetworkCreationFailed = "Network creation failed: timeout"
61+
62+
// testPlacementA is the placement key used in Workload status rollup tests.
63+
testPlacementA = "placement-a"
6164
)
6265

6366
// newTestScheme builds a runtime.Scheme with the types needed for instance reconcile tests.
@@ -2641,6 +2644,85 @@ func TestReconcileInstanceReadyCondition_EvaluateAllThenPick(t *testing.T) {
26412644
})
26422645
}
26432646

2647+
// TestReconcileInstanceReadyCondition_QuotaVsReferencedData is the RFC §8.1
2648+
// headline case: QuotaGranted=False/QuotaExceeded AND
2649+
// ReferencedDataReady=False/SourceNotFound co-occur.
2650+
//
2651+
// SourceNotFound (priority 5) must win over PendingQuota (priority 3) on Ready.
2652+
// Programmed=False and Running=False must still be set (quota side effects are
2653+
// preserved regardless of which reason wins Ready).
2654+
func TestReconcileInstanceReadyCondition_QuotaVsReferencedData(t *testing.T) {
2655+
noNetworkFailure := func(_ context.Context, _ client.Client, _ *computev1alpha.Instance) (bool, string, error) {
2656+
return false, "", nil
2657+
}
2658+
2659+
// Instance has both the Quota gate and the ReferencedData gate, matching the
2660+
// scenario where reconcileQuotaCondition and reconcileReferencedDataCondition
2661+
// have both already run and written their respective sub-conditions.
2662+
inst := &computev1alpha.Instance{
2663+
ObjectMeta: metav1.ObjectMeta{
2664+
Name: testInstanceName,
2665+
Namespace: testDefaultNamespace,
2666+
Generation: 1,
2667+
},
2668+
Spec: computev1alpha.InstanceSpec{
2669+
Controller: &computev1alpha.InstanceController{
2670+
SchedulingGates: []computev1alpha.SchedulingGate{
2671+
{Name: instancecontrol.QuotaSchedulingGate.String()},
2672+
{Name: instancecontrol.ReferencedDataSchedulingGate.String()},
2673+
},
2674+
},
2675+
},
2676+
Status: computev1alpha.InstanceStatus{
2677+
Conditions: []metav1.Condition{
2678+
{
2679+
Type: computev1alpha.InstanceQuotaGranted,
2680+
Status: metav1.ConditionFalse,
2681+
Reason: computev1alpha.InstanceQuotaGrantedReasonQuotaExceeded,
2682+
Message: testMsgQuotaExceeded,
2683+
LastTransitionTime: metav1.Now(),
2684+
},
2685+
{
2686+
Type: computev1alpha.ReferencedDataReady,
2687+
Status: metav1.ConditionFalse,
2688+
Reason: computev1alpha.ReferencedDataReasonSourceNotFound,
2689+
Message: testMsgConfigMapNotFound,
2690+
LastTransitionTime: metav1.Now(),
2691+
},
2692+
},
2693+
},
2694+
}
2695+
2696+
r := &InstanceReconciler{}
2697+
changed, err := r.reconcileInstanceReadyCondition(context.Background(), nil, inst, noNetworkFailure)
2698+
require.NoError(t, err)
2699+
assert.True(t, changed)
2700+
2701+
// Ready must carry the higher-priority SourceNotFound reason (priority 5),
2702+
// not PendingQuota (priority 3).
2703+
readyCond := apimeta.FindStatusCondition(inst.Status.Conditions, computev1alpha.InstanceReady)
2704+
require.NotNil(t, readyCond, "Ready condition must be set")
2705+
assert.Equal(t, metav1.ConditionFalse, readyCond.Status)
2706+
assert.Equal(t, computev1alpha.ReferencedDataReasonSourceNotFound, readyCond.Reason,
2707+
"SourceNotFound (priority 5) must beat PendingQuota (priority 3)")
2708+
assert.Equal(t, testMsgConfigMapNotFound, readyCond.Message,
2709+
"Ready message must be the SourceNotFound message verbatim")
2710+
2711+
// Programmed and Running must still be set to False/PendingQuota — quota
2712+
// side effects are preserved regardless of which reason wins Ready.
2713+
programmedCond := apimeta.FindStatusCondition(inst.Status.Conditions, computev1alpha.InstanceProgrammed)
2714+
require.NotNil(t, programmedCond, "Programmed condition must be set when quota is denied")
2715+
assert.Equal(t, metav1.ConditionFalse, programmedCond.Status)
2716+
assert.Equal(t, computev1alpha.InstanceProgrammedReasonPendingQuota, programmedCond.Reason,
2717+
"Programmed must reflect quota denial even when Ready surfaces a different reason")
2718+
2719+
availableCond := apimeta.FindStatusCondition(inst.Status.Conditions, computev1alpha.InstanceAvailable)
2720+
require.NotNil(t, availableCond, "Available condition must be set when quota is denied")
2721+
assert.Equal(t, metav1.ConditionFalse, availableCond.Status)
2722+
assert.Equal(t, computev1alpha.InstanceProgrammedReasonPendingQuota, availableCond.Reason,
2723+
"Available must reflect quota denial even when Ready surfaces a different reason")
2724+
}
2725+
26442726
// TestInstanceBlockingReasonPriority exhaustively verifies the priority table for
26452727
// Instance.Ready reason selection, as extended in this layer to rank
26462728
// referenced-data reasons. Every listed reason must return the expected integer;

0 commit comments

Comments
 (0)