diff --git a/CHANGELOG.md b/CHANGELOG.md index bba14397e9c..5eb29d24e92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ - Support non-public cloud environments in the Azure Storage Queue and Azure Storage Blob scalers ([#1863](https://github.com/kedacore/keda/pull/1863)) - Show HashiCorp Vault Address when using `kubectl get ta` or `kubectl get cta` ([#1862](https://github.com/kedacore/keda/pull/1862)) - Add fallback functionality ([#1872](https://github.com/kedacore/keda/issues/1872)) +- Introduce Idle Replica Mode ([#1958](https://github.com/kedacore/keda/pull/1958)) ### Improvements diff --git a/api/v1alpha1/scaledobject_types.go b/api/v1alpha1/scaledobject_types.go index f45a287a32d..9ab904ac253 100644 --- a/api/v1alpha1/scaledobject_types.go +++ b/api/v1alpha1/scaledobject_types.go @@ -57,6 +57,8 @@ type ScaledObjectSpec struct { // +optional CooldownPeriod *int32 `json:"cooldownPeriod,omitempty"` // +optional + IdleReplicaCount *int32 `json:"idleReplicaCount,omitempty"` + // +optional MinReplicaCount *int32 `json:"minReplicaCount,omitempty"` // +optional MaxReplicaCount *int32 `json:"maxReplicaCount,omitempty"` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 3ec46460583..e0492ae784c 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -563,6 +563,11 @@ func (in *ScaledObjectSpec) DeepCopyInto(out *ScaledObjectSpec) { *out = new(int32) **out = **in } + if in.IdleReplicaCount != nil { + in, out := &in.IdleReplicaCount, &out.IdleReplicaCount + *out = new(int32) + **out = **in + } if in.MinReplicaCount != nil { in, out := &in.MinReplicaCount, &out.MinReplicaCount *out = new(int32) diff --git a/config/crd/bases/keda.sh_scaledobjects.yaml b/config/crd/bases/keda.sh_scaledobjects.yaml index ea80e62c940..b5de53fc461 100644 --- a/config/crd/bases/keda.sh_scaledobjects.yaml +++ b/config/crd/bases/keda.sh_scaledobjects.yaml @@ -216,6 +216,9 @@ spec: - failureThreshold - replicas type: object + idleReplicaCount: + format: int32 + type: integer maxReplicaCount: format: int32 type: integer diff --git a/controllers/scaledobject_controller.go b/controllers/scaledobject_controller.go index 161bacbd075..0195d179f36 100644 --- a/controllers/scaledobject_controller.go +++ b/controllers/scaledobject_controller.go @@ -200,6 +200,11 @@ func (r *ScaledObjectReconciler) reconcileScaledObject(logger logr.Logger, scale return "ScaledObject doesn't have correct scaleTargetRef specification", err } + err = r.checkReplicaCountBoundsAreValid(scaledObject) + if err != nil { + return "ScaledObject doesn't have correct Idle/Min/Max Replica Counts specification", err + } + // Create a new HPA or update existing one according to ScaledObject newHPACreated, err := r.ensureHPAForScaledObjectExists(logger, scaledObject, &gvkr) if err != nil { @@ -305,6 +310,26 @@ func (r *ScaledObjectReconciler) checkTargetResourceIsScalable(logger logr.Logge return gvkr, nil } +// checkReplicaCountBoundsAreValid checks that Idle/Min/Max ReplicaCount defined in ScaledObject are correctly specified +// ie. that Min is not greater then Max or Idle greater or equal to Min +func (r *ScaledObjectReconciler) checkReplicaCountBoundsAreValid(scaledObject *kedav1alpha1.ScaledObject) error { + min := int32(0) + if scaledObject.Spec.MinReplicaCount != nil { + min = *getHPAMinReplicas(scaledObject) + } + max := getHPAMaxReplicas(scaledObject) + + if min > max { + return fmt.Errorf("MinReplicaCount=%d must be less than MaxReplicaCount=%d", min, max) + } + + if scaledObject.Spec.IdleReplicaCount != nil && *scaledObject.Spec.IdleReplicaCount >= min { + return fmt.Errorf("IdleReplicaCount=%d must be less or equal to MinReplicaCount=%d", *scaledObject.Spec.IdleReplicaCount, min) + } + + return nil +} + // ensureHPAForScaledObjectExists ensures that in cluster exist up-to-date HPA for specified ScaledObject, returns true if a new HPA was created func (r *ScaledObjectReconciler) ensureHPAForScaledObjectExists(logger logr.Logger, scaledObject *kedav1alpha1.ScaledObject, gvkr *kedav1alpha1.GroupVersionKindResource) (bool, error) { hpaName := getHPAName(scaledObject) diff --git a/controllers/scaledobject_controller_test.go b/controllers/scaledobject_controller_test.go index af4a9092b64..bbb35826d67 100644 --- a/controllers/scaledobject_controller_test.go +++ b/controllers/scaledobject_controller_test.go @@ -3,6 +3,7 @@ package controllers import ( "context" "fmt" + "time" "github.com/golang/mock/gomock" . "github.com/onsi/ginkgo" @@ -185,39 +186,9 @@ var _ = Describe("ScaledObjectController", func() { }) Describe("functional tests", func() { - var deployment *appsv1.Deployment - - BeforeEach(func() { - deployment = &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{Name: "myapp", Namespace: "default"}, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "app": "myapp", - }, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - "app": "myapp", - }, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "app", - Image: "app", - }, - }, - }, - }, - }, - } - }) - It("cleans up a deleted trigger from the HPA", func() { // Create the scaling target. - err := k8sClient.Create(context.Background(), deployment) + err := k8sClient.Create(context.Background(), generateDeployment("clean-up")) Expect(err).ToNot(HaveOccurred()) // Create the ScaledObject with two triggers. @@ -225,7 +196,7 @@ var _ = Describe("ScaledObjectController", func() { ObjectMeta: metav1.ObjectMeta{Name: "clean-up-test", Namespace: "default"}, Spec: kedav1alpha1.ScaledObjectSpec{ ScaleTargetRef: &kedav1alpha1.ScaleTarget{ - Name: "myapp", + Name: "clean-up", }, Triggers: []kedav1alpha1.ScaleTriggers{ { @@ -278,5 +249,217 @@ var _ = Describe("ScaledObjectController", func() { // And it should only be the first one left. Expect(hpa.Spec.Metrics[0].External.Metric.Name).To(Equal("cron-UTC-0xxxx-1xxxx")) }) + + It("deploys ScaledObject and creates HPA, when IdleReplicaCount, MinReplicaCount and MaxReplicaCount is defined", func() { + + deploymentName := "idleminmax" + soName := "so-" + deploymentName + + // Create the scaling target. + err := k8sClient.Create(context.Background(), generateDeployment(deploymentName)) + Expect(err).ToNot(HaveOccurred()) + + var one int32 = 1 + var five int32 = 5 + var ten int32 = 10 + + // Create the ScaledObject + so := &kedav1alpha1.ScaledObject{ + ObjectMeta: metav1.ObjectMeta{Name: soName, Namespace: "default"}, + Spec: kedav1alpha1.ScaledObjectSpec{ + ScaleTargetRef: &kedav1alpha1.ScaleTarget{ + Name: deploymentName, + }, + IdleReplicaCount: &one, + MinReplicaCount: &five, + MaxReplicaCount: &ten, + Triggers: []kedav1alpha1.ScaleTriggers{ + { + Type: "cron", + Metadata: map[string]string{ + "timezone": "UTC", + "start": "0 * * * *", + "end": "1 * * * *", + "desiredReplicas": "1", + }, + }, + }, + }, + } + err = k8sClient.Create(context.Background(), so) + Ω(err).ToNot(HaveOccurred()) + + // Get and confirm the HPA + hpa := &autoscalingv2beta2.HorizontalPodAutoscaler{} + Eventually(func() error { + return k8sClient.Get(context.Background(), types.NamespacedName{Name: "keda-hpa-" + soName, Namespace: "default"}, hpa) + }).ShouldNot(HaveOccurred()) + + Ω(*hpa.Spec.MinReplicas).To(Equal(five)) + Ω(hpa.Spec.MaxReplicas).To(Equal(ten)) + + Eventually(func() metav1.ConditionStatus { + err = k8sClient.Get(context.Background(), types.NamespacedName{Name: soName, Namespace: "default"}, so) + Ω(err).ToNot(HaveOccurred()) + return so.Status.Conditions.GetReadyCondition().Status + }, 5*time.Second).Should(Equal(metav1.ConditionTrue)) + }) + + It("doesn't allow MinReplicaCount > MaxReplicaCount", func() { + deploymentName := "minmax" + soName := "so-" + deploymentName + + // Create the scaling target. + err := k8sClient.Create(context.Background(), generateDeployment(deploymentName)) + Expect(err).ToNot(HaveOccurred()) + + var five int32 = 5 + var ten int32 = 10 + + // Create the ScaledObject + so := &kedav1alpha1.ScaledObject{ + ObjectMeta: metav1.ObjectMeta{Name: soName, Namespace: "default"}, + Spec: kedav1alpha1.ScaledObjectSpec{ + ScaleTargetRef: &kedav1alpha1.ScaleTarget{ + Name: deploymentName, + }, + MinReplicaCount: &ten, + MaxReplicaCount: &five, + Triggers: []kedav1alpha1.ScaleTriggers{ + { + Type: "cron", + Metadata: map[string]string{ + "timezone": "UTC", + "start": "0 * * * *", + "end": "1 * * * *", + "desiredReplicas": "1", + }, + }, + }, + }, + } + err = k8sClient.Create(context.Background(), so) + Ω(err).ToNot(HaveOccurred()) + + Eventually(func() metav1.ConditionStatus { + err = k8sClient.Get(context.Background(), types.NamespacedName{Name: soName, Namespace: "default"}, so) + Ω(err).ToNot(HaveOccurred()) + return so.Status.Conditions.GetReadyCondition().Status + }, 5*time.Second).Should(Equal(metav1.ConditionFalse)) + }) + + It("doesn't allow IdleReplicaCount > MinReplicaCount", func() { + deploymentName := "idlemin" + soName := "so-" + deploymentName + + // Create the scaling target. + err := k8sClient.Create(context.Background(), generateDeployment(deploymentName)) + Expect(err).ToNot(HaveOccurred()) + + var five int32 = 5 + var ten int32 = 10 + + // Create the ScaledObject with two triggers + so := &kedav1alpha1.ScaledObject{ + ObjectMeta: metav1.ObjectMeta{Name: soName, Namespace: "default"}, + Spec: kedav1alpha1.ScaledObjectSpec{ + ScaleTargetRef: &kedav1alpha1.ScaleTarget{ + Name: deploymentName, + }, + IdleReplicaCount: &ten, + MinReplicaCount: &five, + Triggers: []kedav1alpha1.ScaleTriggers{ + { + Type: "cron", + Metadata: map[string]string{ + "timezone": "UTC", + "start": "0 * * * *", + "end": "1 * * * *", + "desiredReplicas": "1", + }, + }, + }, + }, + } + err = k8sClient.Create(context.Background(), so) + Ω(err).ToNot(HaveOccurred()) + + Eventually(func() metav1.ConditionStatus { + err = k8sClient.Get(context.Background(), types.NamespacedName{Name: soName, Namespace: "default"}, so) + Ω(err).ToNot(HaveOccurred()) + return so.Status.Conditions.GetReadyCondition().Status + }, 5*time.Second).Should(Equal(metav1.ConditionFalse)) + }) + + It("doesn't allow IdleReplicaCount > MaxReplicaCount, when MinReplicaCount is not explicitly defined", func() { + deploymentName := "idlemax" + soName := "so-" + deploymentName + + // Create the scaling target. + err := k8sClient.Create(context.Background(), generateDeployment(deploymentName)) + Expect(err).ToNot(HaveOccurred()) + + var five int32 = 5 + var ten int32 = 10 + + // Create the ScaledObject with two triggers + so := &kedav1alpha1.ScaledObject{ + ObjectMeta: metav1.ObjectMeta{Name: soName, Namespace: "default"}, + Spec: kedav1alpha1.ScaledObjectSpec{ + ScaleTargetRef: &kedav1alpha1.ScaleTarget{ + Name: deploymentName, + }, + IdleReplicaCount: &ten, + MaxReplicaCount: &five, + Triggers: []kedav1alpha1.ScaleTriggers{ + { + Type: "cron", + Metadata: map[string]string{ + "timezone": "UTC", + "start": "0 * * * *", + "end": "1 * * * *", + "desiredReplicas": "1", + }, + }, + }, + }, + } + err = k8sClient.Create(context.Background(), so) + Ω(err).ToNot(HaveOccurred()) + + Eventually(func() metav1.ConditionStatus { + err = k8sClient.Get(context.Background(), types.NamespacedName{Name: soName, Namespace: "default"}, so) + Ω(err).ToNot(HaveOccurred()) + return so.Status.Conditions.GetReadyCondition().Status + }, 5*time.Second).Should(Equal(metav1.ConditionFalse)) + }) }) }) + +func generateDeployment(name string) *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": name, + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app": name, + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: name, + Image: name, + }, + }, + }, + }, + }, + } +} diff --git a/pkg/mock/mock_client/mock_interfaces.go b/pkg/mock/mock_client/mock_interfaces.go index 66ecb4ec3f6..0ccd0ef55a1 100644 --- a/pkg/mock/mock_client/mock_interfaces.go +++ b/pkg/mock/mock_client/mock_interfaces.go @@ -1,56 +1,43 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: /home/ushio/go/pkg/mod/sigs.k8s.io/controller-runtime@v0.6.0/pkg/client/interfaces.go +// Source: /Users/zroubali/go/pkg/mod/sigs.k8s.io/controller-runtime@v0.6.5/pkg/client/interfaces.go // Package mock_client is a generated GoMock package. package mock_client import ( context "context" + reflect "reflect" + gomock "github.com/golang/mock/gomock" runtime "k8s.io/apimachinery/pkg/runtime" types "k8s.io/apimachinery/pkg/types" - reflect "reflect" client "sigs.k8s.io/controller-runtime/pkg/client" ) -// MockPatch is a mock of Patch interface +// MockPatch is a mock of Patch interface. type MockPatch struct { ctrl *gomock.Controller recorder *MockPatchMockRecorder } -// MockPatchMockRecorder is the mock recorder for MockPatch +// MockPatchMockRecorder is the mock recorder for MockPatch. type MockPatchMockRecorder struct { mock *MockPatch } -// NewMockPatch creates a new mock instance +// NewMockPatch creates a new mock instance. func NewMockPatch(ctrl *gomock.Controller) *MockPatch { mock := &MockPatch{ctrl: ctrl} mock.recorder = &MockPatchMockRecorder{mock} return mock } -// EXPECT returns an object that allows the caller to indicate expected use +// EXPECT returns an object that allows the caller to indicate expected use. func (m *MockPatch) EXPECT() *MockPatchMockRecorder { return m.recorder } -// Type mocks base method -func (m *MockPatch) Type() types.PatchType { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Type") - ret0, _ := ret[0].(types.PatchType) - return ret0 -} - -// Type indicates an expected call of Type -func (mr *MockPatchMockRecorder) Type() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Type", reflect.TypeOf((*MockPatch)(nil).Type)) -} - -// Data mocks base method +// Data mocks base method. func (m *MockPatch) Data(obj runtime.Object) ([]byte, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Data", obj) @@ -59,36 +46,50 @@ func (m *MockPatch) Data(obj runtime.Object) ([]byte, error) { return ret0, ret1 } -// Data indicates an expected call of Data +// Data indicates an expected call of Data. func (mr *MockPatchMockRecorder) Data(obj interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Data", reflect.TypeOf((*MockPatch)(nil).Data), obj) } -// MockReader is a mock of Reader interface +// Type mocks base method. +func (m *MockPatch) Type() types.PatchType { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Type") + ret0, _ := ret[0].(types.PatchType) + return ret0 +} + +// Type indicates an expected call of Type. +func (mr *MockPatchMockRecorder) Type() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Type", reflect.TypeOf((*MockPatch)(nil).Type)) +} + +// MockReader is a mock of Reader interface. type MockReader struct { ctrl *gomock.Controller recorder *MockReaderMockRecorder } -// MockReaderMockRecorder is the mock recorder for MockReader +// MockReaderMockRecorder is the mock recorder for MockReader. type MockReaderMockRecorder struct { mock *MockReader } -// NewMockReader creates a new mock instance +// NewMockReader creates a new mock instance. func NewMockReader(ctrl *gomock.Controller) *MockReader { mock := &MockReader{ctrl: ctrl} mock.recorder = &MockReaderMockRecorder{mock} return mock } -// EXPECT returns an object that allows the caller to indicate expected use +// EXPECT returns an object that allows the caller to indicate expected use. func (m *MockReader) EXPECT() *MockReaderMockRecorder { return m.recorder } -// Get mocks base method +// Get mocks base method. func (m *MockReader) Get(ctx context.Context, key client.ObjectKey, obj runtime.Object) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, key, obj) @@ -96,13 +97,13 @@ func (m *MockReader) Get(ctx context.Context, key client.ObjectKey, obj runtime. return ret0 } -// Get indicates an expected call of Get +// Get indicates an expected call of Get. func (mr *MockReaderMockRecorder) Get(ctx, key, obj interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockReader)(nil).Get), ctx, key, obj) } -// List mocks base method +// List mocks base method. func (m *MockReader) List(ctx context.Context, list runtime.Object, opts ...client.ListOption) error { m.ctrl.T.Helper() varargs := []interface{}{ctx, list} @@ -114,37 +115,37 @@ func (m *MockReader) List(ctx context.Context, list runtime.Object, opts ...clie return ret0 } -// List indicates an expected call of List +// List indicates an expected call of List. func (mr *MockReaderMockRecorder) List(ctx, list interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{ctx, list}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockReader)(nil).List), varargs...) } -// MockWriter is a mock of Writer interface +// MockWriter is a mock of Writer interface. type MockWriter struct { ctrl *gomock.Controller recorder *MockWriterMockRecorder } -// MockWriterMockRecorder is the mock recorder for MockWriter +// MockWriterMockRecorder is the mock recorder for MockWriter. type MockWriterMockRecorder struct { mock *MockWriter } -// NewMockWriter creates a new mock instance +// NewMockWriter creates a new mock instance. func NewMockWriter(ctrl *gomock.Controller) *MockWriter { mock := &MockWriter{ctrl: ctrl} mock.recorder = &MockWriterMockRecorder{mock} return mock } -// EXPECT returns an object that allows the caller to indicate expected use +// EXPECT returns an object that allows the caller to indicate expected use. func (m *MockWriter) EXPECT() *MockWriterMockRecorder { return m.recorder } -// Create mocks base method +// Create mocks base method. func (m *MockWriter) Create(ctx context.Context, obj runtime.Object, opts ...client.CreateOption) error { m.ctrl.T.Helper() varargs := []interface{}{ctx, obj} @@ -156,14 +157,14 @@ func (m *MockWriter) Create(ctx context.Context, obj runtime.Object, opts ...cli return ret0 } -// Create indicates an expected call of Create +// Create indicates an expected call of Create. func (mr *MockWriterMockRecorder) Create(ctx, obj interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{ctx, obj}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockWriter)(nil).Create), varargs...) } -// Delete mocks base method +// Delete mocks base method. func (m *MockWriter) Delete(ctx context.Context, obj runtime.Object, opts ...client.DeleteOption) error { m.ctrl.T.Helper() varargs := []interface{}{ctx, obj} @@ -175,33 +176,33 @@ func (m *MockWriter) Delete(ctx context.Context, obj runtime.Object, opts ...cli return ret0 } -// Delete indicates an expected call of Delete +// Delete indicates an expected call of Delete. func (mr *MockWriterMockRecorder) Delete(ctx, obj interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{ctx, obj}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockWriter)(nil).Delete), varargs...) } -// Update mocks base method -func (m *MockWriter) Update(ctx context.Context, obj runtime.Object, opts ...client.UpdateOption) error { +// DeleteAllOf mocks base method. +func (m *MockWriter) DeleteAllOf(ctx context.Context, obj runtime.Object, opts ...client.DeleteAllOfOption) error { m.ctrl.T.Helper() varargs := []interface{}{ctx, obj} for _, a := range opts { varargs = append(varargs, a) } - ret := m.ctrl.Call(m, "Update", varargs...) + ret := m.ctrl.Call(m, "DeleteAllOf", varargs...) ret0, _ := ret[0].(error) return ret0 } -// Update indicates an expected call of Update -func (mr *MockWriterMockRecorder) Update(ctx, obj interface{}, opts ...interface{}) *gomock.Call { +// DeleteAllOf indicates an expected call of DeleteAllOf. +func (mr *MockWriterMockRecorder) DeleteAllOf(ctx, obj interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{ctx, obj}, opts...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockWriter)(nil).Update), varargs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAllOf", reflect.TypeOf((*MockWriter)(nil).DeleteAllOf), varargs...) } -// Patch mocks base method +// Patch mocks base method. func (m *MockWriter) Patch(ctx context.Context, obj runtime.Object, patch client.Patch, opts ...client.PatchOption) error { m.ctrl.T.Helper() varargs := []interface{}{ctx, obj, patch} @@ -213,56 +214,56 @@ func (m *MockWriter) Patch(ctx context.Context, obj runtime.Object, patch client return ret0 } -// Patch indicates an expected call of Patch +// Patch indicates an expected call of Patch. func (mr *MockWriterMockRecorder) Patch(ctx, obj, patch interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{ctx, obj, patch}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Patch", reflect.TypeOf((*MockWriter)(nil).Patch), varargs...) } -// DeleteAllOf mocks base method -func (m *MockWriter) DeleteAllOf(ctx context.Context, obj runtime.Object, opts ...client.DeleteAllOfOption) error { +// Update mocks base method. +func (m *MockWriter) Update(ctx context.Context, obj runtime.Object, opts ...client.UpdateOption) error { m.ctrl.T.Helper() varargs := []interface{}{ctx, obj} for _, a := range opts { varargs = append(varargs, a) } - ret := m.ctrl.Call(m, "DeleteAllOf", varargs...) + ret := m.ctrl.Call(m, "Update", varargs...) ret0, _ := ret[0].(error) return ret0 } -// DeleteAllOf indicates an expected call of DeleteAllOf -func (mr *MockWriterMockRecorder) DeleteAllOf(ctx, obj interface{}, opts ...interface{}) *gomock.Call { +// Update indicates an expected call of Update. +func (mr *MockWriterMockRecorder) Update(ctx, obj interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{ctx, obj}, opts...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAllOf", reflect.TypeOf((*MockWriter)(nil).DeleteAllOf), varargs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockWriter)(nil).Update), varargs...) } -// MockStatusClient is a mock of StatusClient interface +// MockStatusClient is a mock of StatusClient interface. type MockStatusClient struct { ctrl *gomock.Controller recorder *MockStatusClientMockRecorder } -// MockStatusClientMockRecorder is the mock recorder for MockStatusClient +// MockStatusClientMockRecorder is the mock recorder for MockStatusClient. type MockStatusClientMockRecorder struct { mock *MockStatusClient } -// NewMockStatusClient creates a new mock instance +// NewMockStatusClient creates a new mock instance. func NewMockStatusClient(ctrl *gomock.Controller) *MockStatusClient { mock := &MockStatusClient{ctrl: ctrl} mock.recorder = &MockStatusClientMockRecorder{mock} return mock } -// EXPECT returns an object that allows the caller to indicate expected use +// EXPECT returns an object that allows the caller to indicate expected use. func (m *MockStatusClient) EXPECT() *MockStatusClientMockRecorder { return m.recorder } -// Status mocks base method +// Status mocks base method. func (m *MockStatusClient) Status() client.StatusWriter { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Status") @@ -270,187 +271,187 @@ func (m *MockStatusClient) Status() client.StatusWriter { return ret0 } -// Status indicates an expected call of Status +// Status indicates an expected call of Status. func (mr *MockStatusClientMockRecorder) Status() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Status", reflect.TypeOf((*MockStatusClient)(nil).Status)) } -// MockStatusWriter is a mock of StatusWriter interface +// MockStatusWriter is a mock of StatusWriter interface. type MockStatusWriter struct { ctrl *gomock.Controller recorder *MockStatusWriterMockRecorder } -// MockStatusWriterMockRecorder is the mock recorder for MockStatusWriter +// MockStatusWriterMockRecorder is the mock recorder for MockStatusWriter. type MockStatusWriterMockRecorder struct { mock *MockStatusWriter } -// NewMockStatusWriter creates a new mock instance +// NewMockStatusWriter creates a new mock instance. func NewMockStatusWriter(ctrl *gomock.Controller) *MockStatusWriter { mock := &MockStatusWriter{ctrl: ctrl} mock.recorder = &MockStatusWriterMockRecorder{mock} return mock } -// EXPECT returns an object that allows the caller to indicate expected use +// EXPECT returns an object that allows the caller to indicate expected use. func (m *MockStatusWriter) EXPECT() *MockStatusWriterMockRecorder { return m.recorder } -// Update mocks base method -func (m *MockStatusWriter) Update(ctx context.Context, obj runtime.Object, opts ...client.UpdateOption) error { +// Patch mocks base method. +func (m *MockStatusWriter) Patch(ctx context.Context, obj runtime.Object, patch client.Patch, opts ...client.PatchOption) error { m.ctrl.T.Helper() - varargs := []interface{}{ctx, obj} + varargs := []interface{}{ctx, obj, patch} for _, a := range opts { varargs = append(varargs, a) } - ret := m.ctrl.Call(m, "Update", varargs...) + ret := m.ctrl.Call(m, "Patch", varargs...) ret0, _ := ret[0].(error) return ret0 } -// Update indicates an expected call of Update -func (mr *MockStatusWriterMockRecorder) Update(ctx, obj interface{}, opts ...interface{}) *gomock.Call { +// Patch indicates an expected call of Patch. +func (mr *MockStatusWriterMockRecorder) Patch(ctx, obj, patch interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, obj}, opts...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockStatusWriter)(nil).Update), varargs...) + varargs := append([]interface{}{ctx, obj, patch}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Patch", reflect.TypeOf((*MockStatusWriter)(nil).Patch), varargs...) } -// Patch mocks base method -func (m *MockStatusWriter) Patch(ctx context.Context, obj runtime.Object, patch client.Patch, opts ...client.PatchOption) error { +// Update mocks base method. +func (m *MockStatusWriter) Update(ctx context.Context, obj runtime.Object, opts ...client.UpdateOption) error { m.ctrl.T.Helper() - varargs := []interface{}{ctx, obj, patch} + varargs := []interface{}{ctx, obj} for _, a := range opts { varargs = append(varargs, a) } - ret := m.ctrl.Call(m, "Patch", varargs...) + ret := m.ctrl.Call(m, "Update", varargs...) ret0, _ := ret[0].(error) return ret0 } -// Patch indicates an expected call of Patch -func (mr *MockStatusWriterMockRecorder) Patch(ctx, obj, patch interface{}, opts ...interface{}) *gomock.Call { +// Update indicates an expected call of Update. +func (mr *MockStatusWriterMockRecorder) Update(ctx, obj interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, obj, patch}, opts...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Patch", reflect.TypeOf((*MockStatusWriter)(nil).Patch), varargs...) + varargs := append([]interface{}{ctx, obj}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockStatusWriter)(nil).Update), varargs...) } -// MockClient is a mock of Client interface +// MockClient is a mock of Client interface. type MockClient struct { ctrl *gomock.Controller recorder *MockClientMockRecorder } -// MockClientMockRecorder is the mock recorder for MockClient +// MockClientMockRecorder is the mock recorder for MockClient. type MockClientMockRecorder struct { mock *MockClient } -// NewMockClient creates a new mock instance +// NewMockClient creates a new mock instance. func NewMockClient(ctrl *gomock.Controller) *MockClient { mock := &MockClient{ctrl: ctrl} mock.recorder = &MockClientMockRecorder{mock} return mock } -// EXPECT returns an object that allows the caller to indicate expected use +// EXPECT returns an object that allows the caller to indicate expected use. func (m *MockClient) EXPECT() *MockClientMockRecorder { return m.recorder } -// Get mocks base method -func (m *MockClient) Get(ctx context.Context, key client.ObjectKey, obj runtime.Object) error { +// Create mocks base method. +func (m *MockClient) Create(ctx context.Context, obj runtime.Object, opts ...client.CreateOption) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Get", ctx, key, obj) + varargs := []interface{}{ctx, obj} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Create", varargs...) ret0, _ := ret[0].(error) return ret0 } -// Get indicates an expected call of Get -func (mr *MockClientMockRecorder) Get(ctx, key, obj interface{}) *gomock.Call { +// Create indicates an expected call of Create. +func (mr *MockClientMockRecorder) Create(ctx, obj interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockClient)(nil).Get), ctx, key, obj) + varargs := append([]interface{}{ctx, obj}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockClient)(nil).Create), varargs...) } -// List mocks base method -func (m *MockClient) List(ctx context.Context, list runtime.Object, opts ...client.ListOption) error { +// Delete mocks base method. +func (m *MockClient) Delete(ctx context.Context, obj runtime.Object, opts ...client.DeleteOption) error { m.ctrl.T.Helper() - varargs := []interface{}{ctx, list} + varargs := []interface{}{ctx, obj} for _, a := range opts { varargs = append(varargs, a) } - ret := m.ctrl.Call(m, "List", varargs...) + ret := m.ctrl.Call(m, "Delete", varargs...) ret0, _ := ret[0].(error) return ret0 } -// List indicates an expected call of List -func (mr *MockClientMockRecorder) List(ctx, list interface{}, opts ...interface{}) *gomock.Call { +// Delete indicates an expected call of Delete. +func (mr *MockClientMockRecorder) Delete(ctx, obj interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, list}, opts...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockClient)(nil).List), varargs...) + varargs := append([]interface{}{ctx, obj}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockClient)(nil).Delete), varargs...) } -// Create mocks base method -func (m *MockClient) Create(ctx context.Context, obj runtime.Object, opts ...client.CreateOption) error { +// DeleteAllOf mocks base method. +func (m *MockClient) DeleteAllOf(ctx context.Context, obj runtime.Object, opts ...client.DeleteAllOfOption) error { m.ctrl.T.Helper() varargs := []interface{}{ctx, obj} for _, a := range opts { varargs = append(varargs, a) } - ret := m.ctrl.Call(m, "Create", varargs...) + ret := m.ctrl.Call(m, "DeleteAllOf", varargs...) ret0, _ := ret[0].(error) return ret0 } -// Create indicates an expected call of Create -func (mr *MockClientMockRecorder) Create(ctx, obj interface{}, opts ...interface{}) *gomock.Call { +// DeleteAllOf indicates an expected call of DeleteAllOf. +func (mr *MockClientMockRecorder) DeleteAllOf(ctx, obj interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{ctx, obj}, opts...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockClient)(nil).Create), varargs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAllOf", reflect.TypeOf((*MockClient)(nil).DeleteAllOf), varargs...) } -// Delete mocks base method -func (m *MockClient) Delete(ctx context.Context, obj runtime.Object, opts ...client.DeleteOption) error { +// Get mocks base method. +func (m *MockClient) Get(ctx context.Context, key client.ObjectKey, obj runtime.Object) error { m.ctrl.T.Helper() - varargs := []interface{}{ctx, obj} - for _, a := range opts { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "Delete", varargs...) + ret := m.ctrl.Call(m, "Get", ctx, key, obj) ret0, _ := ret[0].(error) return ret0 } -// Delete indicates an expected call of Delete -func (mr *MockClientMockRecorder) Delete(ctx, obj interface{}, opts ...interface{}) *gomock.Call { +// Get indicates an expected call of Get. +func (mr *MockClientMockRecorder) Get(ctx, key, obj interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, obj}, opts...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockClient)(nil).Delete), varargs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockClient)(nil).Get), ctx, key, obj) } -// Update mocks base method -func (m *MockClient) Update(ctx context.Context, obj runtime.Object, opts ...client.UpdateOption) error { +// List mocks base method. +func (m *MockClient) List(ctx context.Context, list runtime.Object, opts ...client.ListOption) error { m.ctrl.T.Helper() - varargs := []interface{}{ctx, obj} + varargs := []interface{}{ctx, list} for _, a := range opts { varargs = append(varargs, a) } - ret := m.ctrl.Call(m, "Update", varargs...) + ret := m.ctrl.Call(m, "List", varargs...) ret0, _ := ret[0].(error) return ret0 } -// Update indicates an expected call of Update -func (mr *MockClientMockRecorder) Update(ctx, obj interface{}, opts ...interface{}) *gomock.Call { +// List indicates an expected call of List. +func (mr *MockClientMockRecorder) List(ctx, list interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, obj}, opts...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockClient)(nil).Update), varargs...) + varargs := append([]interface{}{ctx, list}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockClient)(nil).List), varargs...) } -// Patch mocks base method +// Patch mocks base method. func (m *MockClient) Patch(ctx context.Context, obj runtime.Object, patch client.Patch, opts ...client.PatchOption) error { m.ctrl.T.Helper() varargs := []interface{}{ctx, obj, patch} @@ -462,70 +463,70 @@ func (m *MockClient) Patch(ctx context.Context, obj runtime.Object, patch client return ret0 } -// Patch indicates an expected call of Patch +// Patch indicates an expected call of Patch. func (mr *MockClientMockRecorder) Patch(ctx, obj, patch interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{ctx, obj, patch}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Patch", reflect.TypeOf((*MockClient)(nil).Patch), varargs...) } -// DeleteAllOf mocks base method -func (m *MockClient) DeleteAllOf(ctx context.Context, obj runtime.Object, opts ...client.DeleteAllOfOption) error { +// Status mocks base method. +func (m *MockClient) Status() client.StatusWriter { m.ctrl.T.Helper() - varargs := []interface{}{ctx, obj} - for _, a := range opts { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "DeleteAllOf", varargs...) - ret0, _ := ret[0].(error) + ret := m.ctrl.Call(m, "Status") + ret0, _ := ret[0].(client.StatusWriter) return ret0 } -// DeleteAllOf indicates an expected call of DeleteAllOf -func (mr *MockClientMockRecorder) DeleteAllOf(ctx, obj interface{}, opts ...interface{}) *gomock.Call { +// Status indicates an expected call of Status. +func (mr *MockClientMockRecorder) Status() *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, obj}, opts...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAllOf", reflect.TypeOf((*MockClient)(nil).DeleteAllOf), varargs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Status", reflect.TypeOf((*MockClient)(nil).Status)) } -// Status mocks base method -func (m *MockClient) Status() client.StatusWriter { +// Update mocks base method. +func (m *MockClient) Update(ctx context.Context, obj runtime.Object, opts ...client.UpdateOption) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Status") - ret0, _ := ret[0].(client.StatusWriter) + varargs := []interface{}{ctx, obj} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Update", varargs...) + ret0, _ := ret[0].(error) return ret0 } -// Status indicates an expected call of Status -func (mr *MockClientMockRecorder) Status() *gomock.Call { +// Update indicates an expected call of Update. +func (mr *MockClientMockRecorder) Update(ctx, obj interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Status", reflect.TypeOf((*MockClient)(nil).Status)) + varargs := append([]interface{}{ctx, obj}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockClient)(nil).Update), varargs...) } -// MockFieldIndexer is a mock of FieldIndexer interface +// MockFieldIndexer is a mock of FieldIndexer interface. type MockFieldIndexer struct { ctrl *gomock.Controller recorder *MockFieldIndexerMockRecorder } -// MockFieldIndexerMockRecorder is the mock recorder for MockFieldIndexer +// MockFieldIndexerMockRecorder is the mock recorder for MockFieldIndexer. type MockFieldIndexerMockRecorder struct { mock *MockFieldIndexer } -// NewMockFieldIndexer creates a new mock instance +// NewMockFieldIndexer creates a new mock instance. func NewMockFieldIndexer(ctrl *gomock.Controller) *MockFieldIndexer { mock := &MockFieldIndexer{ctrl: ctrl} mock.recorder = &MockFieldIndexerMockRecorder{mock} return mock } -// EXPECT returns an object that allows the caller to indicate expected use +// EXPECT returns an object that allows the caller to indicate expected use. func (m *MockFieldIndexer) EXPECT() *MockFieldIndexerMockRecorder { return m.recorder } -// IndexField mocks base method +// IndexField mocks base method. func (m *MockFieldIndexer) IndexField(ctx context.Context, obj runtime.Object, field string, extractValue client.IndexerFunc) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "IndexField", ctx, obj, field, extractValue) @@ -533,7 +534,7 @@ func (m *MockFieldIndexer) IndexField(ctx context.Context, obj runtime.Object, f return ret0 } -// IndexField indicates an expected call of IndexField +// IndexField indicates an expected call of IndexField. func (mr *MockFieldIndexerMockRecorder) IndexField(ctx, obj, field, extractValue interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IndexField", reflect.TypeOf((*MockFieldIndexer)(nil).IndexField), ctx, obj, field, extractValue) diff --git a/pkg/scaling/executor/scale_scaledobjects.go b/pkg/scaling/executor/scale_scaledobjects.go index 52be6e11b3d..c364a042eea 100644 --- a/pkg/scaling/executor/scale_scaledobjects.go +++ b/pkg/scaling/executor/scale_scaledobjects.go @@ -54,54 +54,72 @@ func (e *scaleExecutor) RequestScale(ctx context.Context, scaledObject *kedav1al currentReplicas = currentScale.Spec.Replicas } - switch { - case currentReplicas == 0 && isActive: - // current replica count is 0, but there is an active trigger. - // scale the ScaleTarget up - e.scaleFromZero(ctx, logger, scaledObject, currentScale) - case !isActive && - isError && - scaledObject.Spec.Fallback != nil && - scaledObject.Spec.Fallback.Replicas != 0: - // there are no active triggers, but a scaler responded with an error - // AND - // there is a fallback replicas count defined - - // Scale to the fallback replicas count - e.doFallbackScaling(ctx, scaledObject, currentScale, logger, currentReplicas) - - case !isActive && - currentReplicas > 0 && - (scaledObject.Spec.MinReplicaCount == nil || *scaledObject.Spec.MinReplicaCount == 0): - // there are no active triggers, but the ScaleTarget has replicas. - // AND - // There is no minimum configured or minimum is set to ZERO. HPA will handles other scale down operations - - // Try to scale it down. - e.scaleToZero(ctx, logger, scaledObject, currentScale) - case !isActive && - scaledObject.Spec.MinReplicaCount != nil && - currentReplicas < *scaledObject.Spec.MinReplicaCount: - // there are no active triggers - // AND - // ScaleTarget replicas count is less than minimum replica count specified in ScaledObject - // Let's set ScaleTarget replicas count to correct value - _, err := e.updateScaleOnScaleTarget(ctx, scaledObject, currentScale, *scaledObject.Spec.MinReplicaCount) - if err == nil { - logger.Info("Successfully set ScaleTarget replicas count to ScaledObject minReplicaCount", - "Original Replicas Count", currentReplicas, - "New Replicas Count", *scaledObject.Spec.MinReplicaCount) + if isActive { + switch { + case scaledObject.Spec.IdleReplicaCount != nil && currentReplicas < *scaledObject.Spec.MinReplicaCount, + // triggers are active, Idle Replicas mode is enabled + // AND + // replica count less then minimum replica count + + currentReplicas == 0: + // triggers are active + // AND + // replica count is equal to 0 + + // Scale the ScaleTarget up + e.scaleFromZeroOrIdle(ctx, logger, scaledObject, currentScale) + default: + // triggers are active, but we didn't need to scale (replica count > 0) + + // update LastActiveTime to now + err := e.updateLastActiveTime(ctx, logger, scaledObject) + if err != nil { + logger.Error(err, "Error updating last active time") + return + } } - case isActive: - // triggers are active, but we didn't need to scale (replica count > 0) - // Update LastActiveTime to now. - err := e.updateLastActiveTime(ctx, logger, scaledObject) - if err != nil { - logger.Error(err, "Error updating last active time") - return + } else { + // isActive == false + switch { + case isError && scaledObject.Spec.Fallback != nil && scaledObject.Spec.Fallback.Replicas != 0: + // there are no active triggers, but a scaler responded with an error + // AND + // there is a fallback replicas count defined + + // Scale to the fallback replicas count + e.doFallbackScaling(ctx, scaledObject, currentScale, logger, currentReplicas) + case scaledObject.Spec.IdleReplicaCount != nil && currentReplicas > *scaledObject.Spec.IdleReplicaCount, + // there are no active triggers, Idle Replicas mode is enabled + // AND + // current replicas count is greater than Idle Replicas count + + currentReplicas > 0 && (scaledObject.Spec.MinReplicaCount == nil || *scaledObject.Spec.MinReplicaCount == 0): + // there are no active triggers, but the ScaleTarget has replicas + // AND + // there is no minimum configured or minimum is set to ZERO + + // Try to scale the deployment down, HPA will handle other scale down operations + e.scaleToZeroOrIdle(ctx, logger, scaledObject, currentScale) + case scaledObject.Spec.MinReplicaCount != nil && currentReplicas < *scaledObject.Spec.MinReplicaCount && scaledObject.Spec.IdleReplicaCount == nil: + // there are no active triggers + // AND + // ScaleTarget replicas count is less than minimum replica count specified in ScaledObject + // AND + // Idle Replicas mode is disabled + + // ScaleTarget replicas count to correct value + _, err := e.updateScaleOnScaleTarget(ctx, scaledObject, currentScale, *scaledObject.Spec.MinReplicaCount) + if err == nil { + logger.Info("Successfully set ScaleTarget replicas count to ScaledObject minReplicaCount", + "Original Replicas Count", currentReplicas, + "New Replicas Count", *scaledObject.Spec.MinReplicaCount) + } + default: + // there are no active triggers + // AND + // nothing needs to be done (eg. deployment is scaled down) + logger.V(1).Info("ScaleTarget no change") } - default: - logger.V(1).Info("ScaleTarget no change") } condition := scaledObject.Status.Conditions.GetActiveCondition() @@ -134,7 +152,7 @@ func (e *scaleExecutor) doFallbackScaling(ctx context.Context, scaledObject *ked // An object will be scaled down to 0 only if it's passed its cooldown period // or if LastActiveTime is nil -func (e *scaleExecutor) scaleToZero(ctx context.Context, logger logr.Logger, scaledObject *kedav1alpha1.ScaledObject, scale *autoscalingv1.Scale) { +func (e *scaleExecutor) scaleToZeroOrIdle(ctx context.Context, logger logr.Logger, scaledObject *kedav1alpha1.ScaledObject, scale *autoscalingv1.Scale) { var cooldownPeriod time.Duration if scaledObject.Spec.CooldownPeriod != nil { @@ -143,21 +161,33 @@ func (e *scaleExecutor) scaleToZero(ctx context.Context, logger logr.Logger, sca cooldownPeriod = time.Second * time.Duration(defaultCooldownPeriod) } - // LastActiveTime can be nil if the ScaleTarget was scaled outside of Keda. + // LastActiveTime can be nil if the ScaleTarget was scaled outside of KEDA. // In this case we will ignore the cooldown period and scale it down if scaledObject.Status.LastActiveTime == nil || scaledObject.Status.LastActiveTime.Add(cooldownPeriod).Before(time.Now()) { // or last time a trigger was active was > cooldown period, so scale down. - currentReplicas, err := e.updateScaleOnScaleTarget(ctx, scaledObject, scale, 0) + + idleValue, scaleToReplicas := getIdleOrMinimumReplicaCount(scaledObject) + + currentReplicas, err := e.updateScaleOnScaleTarget(ctx, scaledObject, scale, scaleToReplicas) if err == nil { - logger.Info("Successfully scaled ScaleTarget to 0 replicas") - e.recorder.Eventf(scaledObject, corev1.EventTypeNormal, eventreason.KEDAScaleTargetDeactivated, "Deactivated %s %s/%s from %d to %d", scaledObject.Status.ScaleTargetKind, scaledObject.Namespace, scaledObject.Spec.ScaleTargetRef.Name, currentReplicas, 0) + msg := "Successfully set ScaleTarget replicas count to ScaledObject" + if idleValue { + msg += " idleReplicaCount" + } else { + msg += " minReplicaCount" + } + logger.Info(msg, "Original Replicas Count", currentReplicas, "New Replicas Count", scaleToReplicas) + + e.recorder.Eventf(scaledObject, corev1.EventTypeNormal, eventreason.KEDAScaleTargetDeactivated, + "Deactivated %s %s/%s from %d to %d", scaledObject.Status.ScaleTargetKind, scaledObject.Namespace, scaledObject.Spec.ScaleTargetRef.Name, currentReplicas, scaleToReplicas) if err := e.setActiveCondition(ctx, logger, scaledObject, metav1.ConditionFalse, "ScalerNotActive", "Scaling is not performed because triggers are not active"); err != nil { logger.Error(err, "Error in setting active condition") return } } else { - e.recorder.Eventf(scaledObject, corev1.EventTypeWarning, eventreason.KEDAScaleTargetDeactivationFailed, "Failed to deactivated %s %s/%s", scaledObject.Status.ScaleTargetKind, scaledObject.Namespace, scaledObject.Spec.ScaleTargetRef.Name, currentReplicas, 0) + e.recorder.Eventf(scaledObject, corev1.EventTypeWarning, eventreason.KEDAScaleTargetDeactivationFailed, + "Failed to deactivated %s %s/%s", scaledObject.Status.ScaleTargetKind, scaledObject.Namespace, scaledObject.Spec.ScaleTargetRef.Name, currentReplicas, scaleToReplicas) } } else { logger.V(1).Info("ScaleTarget cooling down", @@ -174,7 +204,7 @@ func (e *scaleExecutor) scaleToZero(ctx context.Context, logger logr.Logger, sca } } -func (e *scaleExecutor) scaleFromZero(ctx context.Context, logger logr.Logger, scaledObject *kedav1alpha1.ScaledObject, scale *autoscalingv1.Scale) { +func (e *scaleExecutor) scaleFromZeroOrIdle(ctx context.Context, logger logr.Logger, scaledObject *kedav1alpha1.ScaledObject, scale *autoscalingv1.Scale) { var replicas int32 if scaledObject.Spec.MinReplicaCount != nil && *scaledObject.Spec.MinReplicaCount > 0 { replicas = *scaledObject.Spec.MinReplicaCount @@ -221,3 +251,17 @@ func (e *scaleExecutor) updateScaleOnScaleTarget(ctx context.Context, scaledObje _, err := e.scaleClient.Scales(scaledObject.Namespace).Update(ctx, scaledObject.Status.ScaleTargetGVKR.GroupResource(), scale, metav1.UpdateOptions{}) return currentReplicas, err } + +// getIdleOrMinimumReplicaCount returns true if the second value returned is from IdleReplicaCount +// it returns false if it is from MinReplicaCount followed by the actual value +func getIdleOrMinimumReplicaCount(scaledObject *kedav1alpha1.ScaledObject) (bool, int32) { + if scaledObject.Spec.IdleReplicaCount != nil { + return true, *scaledObject.Spec.IdleReplicaCount + } + + if scaledObject.Spec.MinReplicaCount == nil { + return false, 0 + } + + return false, *scaledObject.Spec.MinReplicaCount +} diff --git a/pkg/scaling/executor/scale_scaledobjects_test.go b/pkg/scaling/executor/scale_scaledobjects_test.go index 61f8bfe27d3..439ac99cacf 100644 --- a/pkg/scaling/executor/scale_scaledobjects_test.go +++ b/pkg/scaling/executor/scale_scaledobjects_test.go @@ -27,12 +27,12 @@ func TestScaleToFallbackReplicasWhenNotActiveAndIsError(t *testing.T) { scaledObject := v1alpha1.ScaledObject{ ObjectMeta: v1.ObjectMeta{ - Name: "some name", - Namespace: "some namespace", + Name: "name", + Namespace: "namespace", }, Spec: v1alpha1.ScaledObjectSpec{ ScaleTargetRef: &v1alpha1.ScaleTarget{ - Name: "some name", + Name: "name", }, Fallback: &v1alpha1.Fallback{ FailureThreshold: 3, @@ -76,3 +76,308 @@ func TestScaleToFallbackReplicasWhenNotActiveAndIsError(t *testing.T) { condition := scaledObject.Status.Conditions.GetFallbackCondition() assert.Equal(t, true, condition.IsTrue()) } + +func TestScaleToMinReplicasWhenNotActive(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) + + minReplicas := int32(0) + + scaledObject := v1alpha1.ScaledObject{ + ObjectMeta: v1.ObjectMeta{ + Name: "name", + Namespace: "namespace", + }, + Spec: v1alpha1.ScaledObjectSpec{ + ScaleTargetRef: &v1alpha1.ScaleTarget{ + Name: "name", + }, + MinReplicaCount: &minReplicas, + }, + Status: v1alpha1.ScaledObjectStatus{ + ScaleTargetGVKR: &v1alpha1.GroupVersionKindResource{ + Group: "apps", + Kind: "Deployment", + }, + }, + } + + scaledObject.Status.Conditions = *v1alpha1.GetInitializedConditions() + + numberOfReplicas := int32(10) + + client.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).SetArg(2, appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: &numberOfReplicas, + }, + }) + + scale := &autoscalingv1.Scale{ + Spec: autoscalingv1.ScaleSpec{ + Replicas: numberOfReplicas, + }, + } + + 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().Return(statusWriter) + statusWriter.EXPECT().Patch(gomock.Any(), gomock.Any(), gomock.Any()) + + scaleExecutor.RequestScale(context.TODO(), &scaledObject, false, false) + + assert.Equal(t, minReplicas, scale.Spec.Replicas) + condition := scaledObject.Status.Conditions.GetActiveCondition() + assert.Equal(t, true, condition.IsFalse()) +} + +func TestScaleToMinReplicasFromLowerInitialReplicaCount(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) + + minReplicas := int32(5) + + scaledObject := v1alpha1.ScaledObject{ + ObjectMeta: v1.ObjectMeta{ + Name: "name", + Namespace: "namespace", + }, + Spec: v1alpha1.ScaledObjectSpec{ + ScaleTargetRef: &v1alpha1.ScaleTarget{ + Name: "name", + }, + MinReplicaCount: &minReplicas, + }, + Status: v1alpha1.ScaledObjectStatus{ + ScaleTargetGVKR: &v1alpha1.GroupVersionKindResource{ + Group: "apps", + Kind: "Deployment", + }, + }, + } + + scaledObject.Status.Conditions = *v1alpha1.GetInitializedConditions() + + numberOfReplicas := int32(1) + + client.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).SetArg(2, appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: &numberOfReplicas, + }, + }) + + scale := &autoscalingv1.Scale{ + Spec: autoscalingv1.ScaleSpec{ + Replicas: numberOfReplicas, + }, + } + + 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().Return(statusWriter) + statusWriter.EXPECT().Patch(gomock.Any(), gomock.Any(), gomock.Any()) + + scaleExecutor.RequestScale(context.TODO(), &scaledObject, false, false) + + assert.Equal(t, minReplicas, scale.Spec.Replicas) + condition := scaledObject.Status.Conditions.GetActiveCondition() + assert.Equal(t, true, condition.IsFalse()) +} + +func TestScaleFromMinReplicasWhenActive(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) + + minReplicas := int32(0) + + scaledObject := v1alpha1.ScaledObject{ + ObjectMeta: v1.ObjectMeta{ + Name: "name", + Namespace: "namespace", + }, + Spec: v1alpha1.ScaledObjectSpec{ + ScaleTargetRef: &v1alpha1.ScaleTarget{ + Name: "name", + }, + MinReplicaCount: &minReplicas, + }, + Status: v1alpha1.ScaledObjectStatus{ + ScaleTargetGVKR: &v1alpha1.GroupVersionKindResource{ + Group: "apps", + Kind: "Deployment", + }, + }, + } + + scaledObject.Status.Conditions = *v1alpha1.GetInitializedConditions() + + client.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).SetArg(2, appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: &minReplicas, + }, + }) + + scale := &autoscalingv1.Scale{ + Spec: autoscalingv1.ScaleSpec{ + Replicas: minReplicas, + }, + } + + 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.TODO(), &scaledObject, true, false) + + assert.Equal(t, int32(1), scale.Spec.Replicas) + condition := scaledObject.Status.Conditions.GetActiveCondition() + assert.Equal(t, true, condition.IsTrue()) +} + +func TestScaleToIdleReplicasWhenNotActive(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) + + idleReplicas := int32(0) + minReplicas := int32(5) + + scaledObject := v1alpha1.ScaledObject{ + ObjectMeta: v1.ObjectMeta{ + Name: "name", + Namespace: "namespace", + }, + Spec: v1alpha1.ScaledObjectSpec{ + ScaleTargetRef: &v1alpha1.ScaleTarget{ + Name: "name", + }, + IdleReplicaCount: &idleReplicas, + MinReplicaCount: &minReplicas, + }, + Status: v1alpha1.ScaledObjectStatus{ + ScaleTargetGVKR: &v1alpha1.GroupVersionKindResource{ + Group: "apps", + Kind: "Deployment", + }, + }, + } + + scaledObject.Status.Conditions = *v1alpha1.GetInitializedConditions() + + numberOfReplicas := int32(10) + + client.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).SetArg(2, appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: &numberOfReplicas, + }, + }) + + scale := &autoscalingv1.Scale{ + Spec: autoscalingv1.ScaleSpec{ + Replicas: numberOfReplicas, + }, + } + + 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().Return(statusWriter) + statusWriter.EXPECT().Patch(gomock.Any(), gomock.Any(), gomock.Any()) + + scaleExecutor.RequestScale(context.TODO(), &scaledObject, false, false) + + assert.Equal(t, idleReplicas, scale.Spec.Replicas) + condition := scaledObject.Status.Conditions.GetActiveCondition() + assert.Equal(t, true, condition.IsFalse()) +} + +func TestScaleFromIdleToMinReplicasWhenActive(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) + + idleReplicas := int32(0) + minReplicas := int32(5) + + scaledObject := v1alpha1.ScaledObject{ + ObjectMeta: v1.ObjectMeta{ + Name: "name", + Namespace: "namespace", + }, + Spec: v1alpha1.ScaledObjectSpec{ + ScaleTargetRef: &v1alpha1.ScaleTarget{ + Name: "name", + }, + IdleReplicaCount: &idleReplicas, + MinReplicaCount: &minReplicas, + }, + Status: v1alpha1.ScaledObjectStatus{ + ScaleTargetGVKR: &v1alpha1.GroupVersionKindResource{ + Group: "apps", + Kind: "Deployment", + }, + }, + } + + scaledObject.Status.Conditions = *v1alpha1.GetInitializedConditions() + + client.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).SetArg(2, appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: &idleReplicas, + }, + }) + + scale := &autoscalingv1.Scale{ + Spec: autoscalingv1.ScaleSpec{ + Replicas: idleReplicas, + }, + } + + 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.TODO(), &scaledObject, true, false) + + assert.Equal(t, minReplicas, scale.Spec.Replicas) + condition := scaledObject.Status.Conditions.GetActiveCondition() + assert.Equal(t, true, condition.IsTrue()) +} diff --git a/tests/scalers/azure-queue-idle-replicas.test.ts b/tests/scalers/azure-queue-idle-replicas.test.ts new file mode 100644 index 00000000000..3212f4defad --- /dev/null +++ b/tests/scalers/azure-queue-idle-replicas.test.ts @@ -0,0 +1,180 @@ +import * as async from 'async' +import * as fs from 'fs' +import * as azure from 'azure-storage' +import * as sh from 'shelljs' +import * as tmp from 'tmp' +import test from 'ava' + +const defaultNamespace = 'azure-queue-idle-replicas-test' +const queueName = 'idle-replicas-queue-name' +const connectionString = process.env['TEST_STORAGE_CONNECTION_STRING'] + +test.before(t => { + if (!connectionString) { + t.fail('TEST_STORAGE_CONNECTION_STRING environment variable is required for queue tests') + } + + sh.config.silent = true + const base64ConStr = Buffer.from(connectionString).toString('base64') + const tmpFile = tmp.fileSync() + fs.writeFileSync(tmpFile.name, deployYaml.replace('{{CONNECTION_STRING_BASE64}}', base64ConStr)) + sh.exec(`kubectl create namespace ${defaultNamespace}`) + t.is( + 0, + sh.exec(`kubectl apply -f ${tmpFile.name} --namespace ${defaultNamespace}`).code, + 'creating a deployment should work.' + ) +}) + +test.serial('Deployment should have 1 replicas on start', t => { + const replicaCount = sh.exec( + `kubectl get deployment.apps/test-deployment --namespace ${defaultNamespace} -o jsonpath="{.spec.replicas}"` + ).stdout + t.is(replicaCount, '1', 'replica count should start out as 1') +}) + +test.serial('Creating ScaledObject should work', t => { + const tmpFile = tmp.fileSync() + fs.writeFileSync(tmpFile.name, scaledObjectYaml) + + t.is( + 0, + sh.exec(`kubectl apply -f ${tmpFile.name} --namespace ${defaultNamespace}`).code, + 'creating a ScaledObject should work.' + ) +}) + + +test.serial( + 'Deployment should scale to 0 - to idleReplicaCount', + t => { + let replicaCount = '100' + for (let i = 0; i < 50 && replicaCount !== '0'; i++) { + replicaCount = sh.exec( + `kubectl get deployment.apps/test-deployment --namespace ${defaultNamespace} -o jsonpath="{.spec.replicas}"` + ).stdout + if (replicaCount !== '0') { + sh.exec('sleep 5s') + } + } + t.is('0', replicaCount, 'Replica count should be 0') + } +) + +test.serial.cb( + 'Deployment should scale from idleReplicaCount (0) to minReplicaCount (2) with messages on storage', + t => { + const queueSvc = azure.createQueueService(connectionString) + queueSvc.messageEncoder = new azure.QueueMessageEncoder.TextBase64QueueMessageEncoder() + queueSvc.createQueueIfNotExists(queueName, err => { + t.falsy(err, 'unable to create queue') + async.mapLimit( + Array(1000).keys(), + 20, + (n, cb) => queueSvc.createMessage(queueName, `test ${n}`, cb), + () => { + let replicaCount = '0' + for (let i = 0; i < 20 && replicaCount !== '2'; i++) { + replicaCount = sh.exec( + `kubectl get deployment.apps/test-deployment --namespace ${defaultNamespace} -o jsonpath="{.spec.replicas}"` + ).stdout + if (replicaCount !== '2') { + sh.exec('sleep 1s') + } + } + + t.is('2', replicaCount, 'Replica count should be 2 after 20 seconds') + queueSvc.deleteQueueIfExists(queueName, err => { + t.falsy(err, `unable to delete queue ${queueName}`) + for (let i = 0; i < 50 && replicaCount !== '0'; i++) { + replicaCount = sh.exec( + `kubectl get deployment.apps/test-deployment --namespace ${defaultNamespace} -o jsonpath="{.spec.replicas}"` + ).stdout + if (replicaCount !== '0') { + sh.exec('sleep 5s') + } + } + + t.is('0', replicaCount, 'Replica count should be 0 after 3 minutes') + t.end() + }) + } + ) + }) + } +) + +test.after.always.cb('clean up azure-queue deployment', t => { + const resources = [ + 'scaledobject.keda.sh/test-scaledobject', + 'secret/test-secrets', + 'deployment.apps/test-deployment', + ] + + for (const resource of resources) { + sh.exec(`kubectl delete ${resource} --namespace ${defaultNamespace}`) + } + sh.exec(`kubectl delete namespace ${defaultNamespace}`) + t.end() +}) + +const deployYaml = `apiVersion: v1 +kind: Secret +metadata: + name: test-secrets + labels: +data: + AzureWebJobsStorage: {{CONNECTION_STRING_BASE64}} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: test-deployment + labels: + app: test-deployment +spec: + replicas: 1 + selector: + matchLabels: + app: test-deployment + template: + metadata: + name: + namespace: + labels: + app: test-deployment + spec: + containers: + - name: test-deployment + image: docker.io/kedacore/tests-azure-queue:824031e + resources: + ports: + env: + - name: FUNCTIONS_WORKER_RUNTIME + value: node + - name: AzureWebJobsStorage + valueFrom: + secretKeyRef: + name: test-secrets + key: AzureWebJobsStorage` + + +const scaledObjectYaml = `apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: + name: test-scaledobject +spec: + advanced: + restoreToOriginalReplicaCount: true + scaleTargetRef: + name: test-deployment + pollingInterval: 5 + idleReplicaCount: 0 + minReplicaCount: 2 + maxReplicaCount: 4 + cooldownPeriod: 10 + triggers: + - type: azure-queue + metadata: + queueName: queue-name + connectionFromEnv: AzureWebJobsStorage`