container: cover the status that has not caught up with the scale request

Turn the scale-from-zero regression into a table so it covers both the pre-scale read and a status the controller has not written yet, and give the type assertion in the reactor the SAFETY comment the lint gate wants.
This commit is contained in:
dualfroz committed 2026-10-15 13:15:02 +02:00
1 parent 22d672c57e
commit fb9eb7fe1c
1 file changed
+47 -10
@@ -310,12 +310,48 @@ func TestContainerGetResources(t *testing.T) {
} }
// TestContainerScaleFromZeroWaitsForAvailability covers the scale-to-zero race // TestContainerScaleFromZeroWaitsForAvailability covers the scale-to-zero race
// reported in #3183. The first read reports one available replica (the pod the // reported in #3183. On the scale-from-zero path the deployment status can
// idle reaper is tearing down) and every later read reports none, so a caller // still describe the pod the idle reaper is tearing down, either because it
// that trusts the pre-scale status reports success with no serving pod. // was read before the scale request or because the controller has not written
// a status for that request yet. A caller that trusts such a status reports
// success while the Service has no endpoint and the router forwards into it.
func TestContainerScaleFromZeroWaitsForAvailability(t *testing.T) { func TestContainerScaleFromZeroWaitsForAvailability(t *testing.T) {
t.Parallel() t.Parallel()
// Generation of the spec after the scale request.
const generation = 2
tests := []struct {
name string
// status is the deployment status served by the nth read, 1-based.
status func(read int) appsv1.DeploymentStatus
}{
{
// The copy createOrGetDeployment read before scaling up.
name: "pre-scale read still counts the terminating pod",
status: func(read int) appsv1.DeploymentStatus {
available := int32(0)
if read == 1 {
available = 1
}
return appsv1.DeploymentStatus{ObservedGeneration: generation, AvailableReplicas: available}
},
},
{
// Status is written asynchronously by the deployment controller, so
// every read up to the deadline can still describe the generation
// before the scale request.
name: "status has not caught up with the scale request",
status: func(int) appsv1.DeploymentStatus {
return appsv1.DeploymentStatus{ObservedGeneration: generation - 1, AvailableReplicas: 1}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fn := newTestContainerFunction() fn := newTestContainerFunction()
fn.Spec.PodSpec.Containers[0].Ports = []apiv1.ContainerPort{{ContainerPort: 8080}} fn.Spec.PodSpec.Containers[0].Ports = []apiv1.ContainerPort{{ContainerPort: 8080}}
fn.Spec.InvokeStrategy.ExecutionStrategy.MinScale = 0 fn.Spec.InvokeStrategy.ExecutionStrategy.MinScale = 0
@@ -326,20 +362,18 @@ func TestContainerScaleFromZeroWaitsForAvailability(t *testing.T) {
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: "ctr-fn", Name: "ctr-fn",
Namespace: ns, Namespace: ns,
Generation: generation,
Annotations: map[string]string{fv1.EXECUTOR_INSTANCEID_LABEL: "test-instance"}, Annotations: map[string]string{fv1.EXECUTOR_INSTANCEID_LABEL: "test-instance"},
}, },
Spec: appsv1.DeploymentSpec{Replicas: &zero}, Spec: appsv1.DeploymentSpec{Replicas: &zero},
Status: appsv1.DeploymentStatus{AvailableReplicas: 0},
} }
kubeClient := fake.NewClientset(scaledIn) kubeClient := fake.NewClientset(scaledIn)
var reads int var reads int
kubeClient.PrependReactor("get", "deployments", func(action k8stesting.Action) (bool, runtime.Object, error) { kubeClient.PrependReactor("get", "deployments", func(k8stesting.Action) (bool, runtime.Object, error) {
depl := scaledIn.DeepCopy() depl := scaledIn.DeepCopy()
reads++ reads++
if reads == 1 { depl.Status = tt.status(reads)
depl.Status.AvailableReplicas = 1
}
return true, depl, nil return true, depl, nil
}) })
// The fake clientset has no built-in scale subresource for deployments. // The fake clientset has no built-in scale subresource for deployments.
@@ -347,6 +381,7 @@ func TestContainerScaleFromZeroWaitsForAvailability(t *testing.T) {
if action.GetSubresource() != "scale" { if action.GetSubresource() != "scale" {
return false, nil, nil return false, nil, nil
} }
// SAFETY: this reactor is registered for "update" and returns early unless the subresource is scale, so the action is an UpdateAction.
return true, action.(k8stesting.UpdateAction).GetObject(), nil return true, action.(k8stesting.UpdateAction).GetObject(), nil
}) })
@@ -358,8 +393,8 @@ func TestContainerScaleFromZeroWaitsForAvailability(t *testing.T) {
runtimeImagePullPolicy: apiv1.PullIfNotPresent, runtimeImagePullPolicy: apiv1.PullIfNotPresent,
} }
// The deployment never becomes available in the fake cluster, so a correct // The deployment never becomes available in the fake cluster, so a
// implementation runs the readiness wait out to this deadline. // correct implementation runs the readiness wait out to this deadline.
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
defer cancel() defer cancel()
@@ -367,4 +402,6 @@ func TestContainerScaleFromZeroWaitsForAvailability(t *testing.T) {
require.Error(t, err, "scale-from-zero must not report success while no replica is available") require.Error(t, err, "scale-from-zero must not report success while no replica is available")
assert.ErrorIs(t, err, context.DeadlineExceeded, assert.ErrorIs(t, err, context.DeadlineExceeded,
"expected the readiness wait to run to its deadline, got: %v", err) "expected the readiness wait to run to its deadline, got: %v", err)
})
}
} }