diff --git a/CHANGELOG.md b/CHANGELOG.md index c47932b3519..4712c1720cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,7 +59,7 @@ To learn more about active deprecations, we recommend checking [GitHub Discussio ### New -- **General**: Add fallback `behavior` with option `useCurrentReplicasAsMinimum` to use current number of replicas ([#6450](https://github.com/kedacore/keda/issues/6450)) +- **General**: Add Fallback option `behavior` for dynamic fallback calculation ([#6450](https://github.com/kedacore/keda/issues/6450)) - **General**: Enable OpenSSF Scorecard to enhance security practices across the project ([#5913](https://github.com/kedacore/keda/issues/5913)) - **General**: Introduce new NSQ scaler ([#3281](https://github.com/kedacore/keda/issues/3281)) - **General**: Operator flag to control patching of webhook resources certificates ([#6184](https://github.com/kedacore/keda/issues/6184)) diff --git a/apis/keda/v1alpha1/scaledobject_types.go b/apis/keda/v1alpha1/scaledobject_types.go index c2c670819ec..4d45fd2789a 100644 --- a/apis/keda/v1alpha1/scaledobject_types.go +++ b/apis/keda/v1alpha1/scaledobject_types.go @@ -56,8 +56,9 @@ const ScaledObjectTransferHpaOwnershipAnnotation = "scaledobject.keda.sh/transfe const ValidationsHpaOwnershipAnnotation = "validations.keda.sh/hpa-ownership" const PausedReplicasAnnotation = "autoscaling.keda.sh/paused-replicas" const PausedAnnotation = "autoscaling.keda.sh/paused" -const FallbackBehaviorStatic = "static" -const FallbackBehaviorUseCurrentReplicasAsMin = "useCurrentReplicasAsMinimum" +const FallbackBehaviorStatic = "Static" +const FallbackBehaviorCurrentReplicasIfHigher = "CurrentReplicasIfHigher" +const FallbackBehaviorCurrentReplicasIfLower = "CurrentReplicasIfLower" // HealthStatus is the status for a ScaledObject's health type HealthStatus struct { @@ -112,8 +113,8 @@ type Fallback struct { FailureThreshold int32 `json:"failureThreshold"` Replicas int32 `json:"replicas"` // +optional - // +kubebuilder:default=static - // +kubebuilder:validation:Enum=static;useCurrentReplicasAsMinimum + // +kubebuilder:default=Static + // +kubebuilder:validation:Enum=Static;CurrentReplicasIfHigher;CurrentReplicasIfLower Behavior string `json:"behavior,omitempty"` } diff --git a/config/crd/bases/keda.sh_scaledobjects.yaml b/config/crd/bases/keda.sh_scaledobjects.yaml index c6d7d4c6a5d..c25e2361763 100644 --- a/config/crd/bases/keda.sh_scaledobjects.yaml +++ b/config/crd/bases/keda.sh_scaledobjects.yaml @@ -226,10 +226,11 @@ spec: description: Fallback is the spec for fallback options properties: behavior: - default: static + default: Static enum: - - static - - useCurrentReplicasAsMinimum + - Static + - CurrentReplicasIfHigher + - CurrentReplicasIfLower type: string failureThreshold: format: int32 diff --git a/pkg/fallback/fallback.go b/pkg/fallback/fallback.go index d63a12804d4..05f31299717 100644 --- a/pkg/fallback/fallback.go +++ b/pkg/fallback/fallback.go @@ -117,13 +117,20 @@ func doFallback(scaledObject *kedav1alpha1.ScaledObject, metricSpec v2.MetricSpe switch fallbackBehavior { case kedav1alpha1.FallbackBehaviorStatic: replicas = fallbackReplicas - case kedav1alpha1.FallbackBehaviorUseCurrentReplicasAsMin: + case kedav1alpha1.FallbackBehaviorCurrentReplicasIfHigher: currentReplicasCount := int64(currentReplicas) if currentReplicasCount > fallbackReplicas { replicas = currentReplicasCount } else { replicas = fallbackReplicas } + case kedav1alpha1.FallbackBehaviorCurrentReplicasIfLower: + currentReplicasCount := int64(currentReplicas) + if currentReplicasCount < fallbackReplicas { + replicas = currentReplicasCount + } else { + replicas = fallbackReplicas + } default: replicas = fallbackReplicas } diff --git a/pkg/fallback/fallback_test.go b/pkg/fallback/fallback_test.go index 78687356bc7..c64e55f2c35 100644 --- a/pkg/fallback/fallback_test.go +++ b/pkg/fallback/fallback_test.go @@ -49,10 +49,10 @@ func TestFallback(t *testing.T) { var _ = Describe("fallback", func() { var ( - client *mock_client.MockClient - scaler *mock_scalers.MockScaler + client *mock_client.MockClient + scaler *mock_scalers.MockScaler scaleClient *mock_scale.MockScalesGetter - ctrl *gomock.Controller + ctrl *gomock.Controller ) BeforeEach(func() { @@ -375,10 +375,10 @@ var _ = Describe("fallback", func() { Expect(condition.IsTrue()).Should(BeFalse()) }) - It("should use fallback replicas when current replicas is lower", func() { + It("should use fallback replicas when current replicas is lower when behavior is 'CurrentReplicasIfHigher'", func() { scaler.EXPECT().GetMetricsAndActivity(gomock.Any(), gomock.Eq(metricName)).Return(nil, false, errors.New("some error")) startingNumberOfFailures := int32(3) - behavior := "useCurrentReplicasAsMinimum" + behavior := "CurrentReplicasIfHigher" so := buildScaledObject( &kedav1alpha1.Fallback{ @@ -409,10 +409,10 @@ var _ = Describe("fallback", func() { Expect(value).Should(Equal(expectedValue)) }) - It("should ignore current replicas when behavior is 'static'", func() { + It("should ignore current replicas when behavior is 'Static'", func() { scaler.EXPECT().GetMetricsAndActivity(gomock.Any(), gomock.Eq(metricName)).Return(nil, false, errors.New("some error")) startingNumberOfFailures := int32(3) - behavior := "static" + behavior := "Static" so := buildScaledObject( &kedav1alpha1.Fallback{ @@ -455,7 +455,7 @@ func mockScaleAndDeployment( mockScaleInterface := mock_scale.NewMockScaleInterface(ctrl) scale := &autoscalingv1.Scale{ ObjectMeta: metav1.ObjectMeta{ - Name: "myapp", + Name: "myapp", Namespace: "default", }, Spec: autoscalingv1.ScaleSpec{ diff --git a/pkg/scaling/executor/scale_scaledobjects.go b/pkg/scaling/executor/scale_scaledobjects.go index c4ee9324f40..cdef0844e68 100644 --- a/pkg/scaling/executor/scale_scaledobjects.go +++ b/pkg/scaling/executor/scale_scaledobjects.go @@ -212,10 +212,14 @@ func (e *scaleExecutor) doFallbackScaling(ctx context.Context, scaledObject *ked switch fallbackBehavior { case kedav1alpha1.FallbackBehaviorStatic: // no specifc action needed - case kedav1alpha1.FallbackBehaviorUseCurrentReplicasAsMin: + case kedav1alpha1.FallbackBehaviorCurrentReplicasIfHigher: if currentReplicas > fallbackReplicas { fallbackReplicas = currentReplicas } + case kedav1alpha1.FallbackBehaviorCurrentReplicasIfLower: + if currentReplicas < fallbackReplicas { + fallbackReplicas = currentReplicas + } } _, err := e.updateScaleOnScaleTarget(ctx, scaledObject, currentScale, fallbackReplicas) diff --git a/pkg/scaling/executor/scale_scaledobjects_test.go b/pkg/scaling/executor/scale_scaledobjects_test.go index b58fa168571..88fb97ea8ef 100644 --- a/pkg/scaling/executor/scale_scaledobjects_test.go +++ b/pkg/scaling/executor/scale_scaledobjects_test.go @@ -523,8 +523,8 @@ func TestEventWitTriggerInfo(t *testing.T) { assert.Equal(t, "Normal KEDAScaleTargetActivated Scaled namespace/name from 2 to 5, triggered by testTrigger", eventstring) } -// Behavior is UseCurrentReplicasAsMinimum and current replicas is higher than fallback replicas -func TestScaleToFallbackWithCurrentReplicasAsMinimum(t *testing.T) { +// Behavior is 'CurrentReplicasIfHigher' and current replicas is higher than fallback replicas +func TestBehaviorCurrentReplicasIfHigherWithCurrentReplicasIsHigher(t *testing.T) { ctrl := gomock.NewController(t) client := mock_client.NewMockClient(ctrl) recorder := record.NewFakeRecorder(1) @@ -534,7 +534,7 @@ func TestScaleToFallbackWithCurrentReplicasAsMinimum(t *testing.T) { scaleExecutor := NewScaleExecutor(client, mockScaleClient, nil, recorder) - behavior := "useCurrentReplicasAsMinimum" + behavior := "CurrentReplicasIfHigher" scaledObject := v1alpha1.ScaledObject{ ObjectMeta: v1.ObjectMeta{ Name: "name", @@ -590,8 +590,8 @@ func TestScaleToFallbackWithCurrentReplicasAsMinimum(t *testing.T) { assert.Equal(t, true, condition.IsTrue()) } -// Behavior is UseCurrentReplicasAsMinimum and is true and current replicas is lower than fallback replicas -func TestScaleToFallbackIgnoringLowerCurrentReplicas(t *testing.T) { +// Behavior is 'CurrentReplicasIfLower' and current replicas is higher than fallback replicas +func TestBehaviorCurrentReplicasIfLowerWithCurrentReplicasIsHigher(t *testing.T) { ctrl := gomock.NewController(t) client := mock_client.NewMockClient(ctrl) recorder := record.NewFakeRecorder(1) @@ -601,7 +601,75 @@ func TestScaleToFallbackIgnoringLowerCurrentReplicas(t *testing.T) { scaleExecutor := NewScaleExecutor(client, mockScaleClient, nil, recorder) - behavior := "useCurrentReplicasAsMinimum" + behavior := "CurrentReplicasIfLower" + scaledObject := v1alpha1.ScaledObject{ + ObjectMeta: v1.ObjectMeta{ + Name: "name", + Namespace: "namespace", + }, + Spec: v1alpha1.ScaledObjectSpec{ + ScaleTargetRef: &v1alpha1.ScaleTarget{ + Name: "name", + }, + Fallback: &v1alpha1.Fallback{ + FailureThreshold: 3, + Replicas: 5, + Behavior: behavior, + }, + }, + Status: v1alpha1.ScaledObjectStatus{ + ScaleTargetGVKR: &v1alpha1.GroupVersionKindResource{ + Group: "apps", + Kind: "Deployment", + }, + }, + } + + scaledObject.Status.Conditions = *v1alpha1.GetInitializedConditions() + + // Current replicas is higher than fallback replicas + currentReplicas := int32(8) + + client.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).SetArg(2, appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: ¤tReplicas, + }, + }) + + scale := &autoscalingv1.Scale{ + Spec: autoscalingv1.ScaleSpec{ + Replicas: currentReplicas, + }, + } + + mockScaleClient.EXPECT().Scales(gomock.Any()).Return(mockScaleInterface).Times(2) + mockScaleInterface.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(scale, nil) + mockScaleInterface.EXPECT().Update(gomock.Any(), gomock.Any(), gomock.Eq(scale), gomock.Any()) + + client.EXPECT().Status().Times(2).Return(statusWriter) + statusWriter.EXPECT().Patch(gomock.Any(), gomock.Any(), gomock.Any()).Times(2) + + scaleExecutor.RequestScale(context.Background(), &scaledObject, false, true, &ScaleExecutorOptions{}) + + // Should use fallback replicas as it's higher than current replicas + assert.Equal(t, int32(5), scale.Spec.Replicas) + condition := scaledObject.Status.Conditions.GetFallbackCondition() + assert.Equal(t, true, condition.IsTrue()) +} + + +// Behavior is 'CurrentReplicasIfHigher' and current replicas is lower than fallback replicas +func TestBehaviorCurrentReplicasIfHigherWithCurrentReplicasisLower(t *testing.T) { + ctrl := gomock.NewController(t) + client := mock_client.NewMockClient(ctrl) + recorder := record.NewFakeRecorder(1) + mockScaleClient := mock_scale.NewMockScalesGetter(ctrl) + mockScaleInterface := mock_scale.NewMockScaleInterface(ctrl) + statusWriter := mock_client.NewMockStatusWriter(ctrl) + + scaleExecutor := NewScaleExecutor(client, mockScaleClient, nil, recorder) + + behavior := "CurrentReplicasIfHigher" scaledObject := v1alpha1.ScaledObject{ ObjectMeta: v1.ObjectMeta{ Name: "name", @@ -658,7 +726,7 @@ func TestScaleToFallbackIgnoringLowerCurrentReplicas(t *testing.T) { } // Behavior is Static and current replicas is higher than fallback replicas -func TestScaleToFallbackWithoutCurrentReplicasAsMinimum(t *testing.T) { +func TestBehaviorStaticWithCurrentReplicasisHigher(t *testing.T) { ctrl := gomock.NewController(t) client := mock_client.NewMockClient(ctrl) recorder := record.NewFakeRecorder(1) @@ -668,7 +736,7 @@ func TestScaleToFallbackWithoutCurrentReplicasAsMinimum(t *testing.T) { scaleExecutor := NewScaleExecutor(client, mockScaleClient, nil, recorder) - behavior := "static" + behavior := "Static" scaledObject := v1alpha1.ScaledObject{ ObjectMeta: v1.ObjectMeta{ Name: "name",