karmada/pkg/controllers/federatedhpa/federatedhpa_controller_tes...

1705 lines
54 KiB
Go

/*
Copyright 2024 The Karmada Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package federatedhpa
import (
"context"
"errors"
"fmt"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
autoscalingv2 "k8s.io/api/autoscaling/v2"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
"k8s.io/utils/ptr"
"sigs.k8s.io/controller-runtime/pkg/client"
autoscalingv1alpha1 "github.com/karmada-io/karmada/pkg/apis/autoscaling/v1alpha1"
clusterv1alpha1 "github.com/karmada-io/karmada/pkg/apis/cluster/v1alpha1"
policyv1alpha1 "github.com/karmada-io/karmada/pkg/apis/policy/v1alpha1"
workv1alpha2 "github.com/karmada-io/karmada/pkg/apis/work/v1alpha2"
"github.com/karmada-io/karmada/pkg/util/lifted/selectors"
)
type MockClient struct {
mock.Mock
}
func (m *MockClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
args := m.Called(ctx, key, obj, opts)
return args.Error(0)
}
func (m *MockClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error {
args := m.Called(ctx, list, opts)
return args.Error(0)
}
func (m *MockClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error {
args := m.Called(ctx, obj, opts)
return args.Error(0)
}
func (m *MockClient) Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error {
args := m.Called(ctx, obj, opts)
return args.Error(0)
}
func (m *MockClient) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error {
args := m.Called(ctx, obj, opts)
return args.Error(0)
}
func (m *MockClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error {
args := m.Called(ctx, obj, patch, opts)
return args.Error(0)
}
func (m *MockClient) DeleteAllOf(ctx context.Context, obj client.Object, opts ...client.DeleteAllOfOption) error {
args := m.Called(ctx, obj, opts)
return args.Error(0)
}
func (m *MockClient) Status() client.StatusWriter {
args := m.Called()
return args.Get(0).(client.StatusWriter)
}
func (m *MockClient) Scheme() *runtime.Scheme {
args := m.Called()
return args.Get(0).(*runtime.Scheme)
}
func (m *MockClient) SubResource(subResource string) client.SubResourceClient {
args := m.Called(subResource)
return args.Get(0).(client.SubResourceClient)
}
func (m *MockClient) RESTMapper() meta.RESTMapper {
args := m.Called()
return args.Get(0).(meta.RESTMapper)
}
func (m *MockClient) GroupVersionKindFor(obj runtime.Object) (schema.GroupVersionKind, error) {
args := m.Called(obj)
return args.Get(0).(schema.GroupVersionKind), args.Error(1)
}
func (m *MockClient) IsObjectNamespaced(obj runtime.Object) (bool, error) {
args := m.Called(obj)
return args.Bool(0), args.Error(1)
}
// TestGetBindingByLabel verifies the behavior of getBindingByLabel function
func TestGetBindingByLabel(t *testing.T) {
tests := []struct {
name string
resourceLabel map[string]string
resourceRef autoscalingv2.CrossVersionObjectReference
bindingList *workv1alpha2.ResourceBindingList
expectedError string
}{
{
name: "Successful retrieval",
resourceLabel: map[string]string{
policyv1alpha1.PropagationPolicyPermanentIDLabel: "test-policy-id",
},
resourceRef: autoscalingv2.CrossVersionObjectReference{
Kind: "Deployment",
Name: "test-deployment",
APIVersion: "apps/v1",
},
bindingList: &workv1alpha2.ResourceBindingList{
Items: []workv1alpha2.ResourceBinding{
{
Spec: workv1alpha2.ResourceBindingSpec{
Resource: workv1alpha2.ObjectReference{
APIVersion: "apps/v1",
Kind: "Deployment",
Name: "test-deployment",
},
},
},
},
},
},
{
name: "Empty resource label",
resourceLabel: map[string]string{},
expectedError: "target resource has no label",
},
{
name: "No matching bindings",
resourceLabel: map[string]string{
policyv1alpha1.PropagationPolicyPermanentIDLabel: "test-policy-id",
},
resourceRef: autoscalingv2.CrossVersionObjectReference{
Kind: "Deployment",
Name: "non-existent-deployment",
APIVersion: "apps/v1",
},
bindingList: &workv1alpha2.ResourceBindingList{
Items: []workv1alpha2.ResourceBinding{},
},
expectedError: "length of binding list is zero",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := new(MockClient)
controller := &FHPAController{
Client: mockClient,
}
ctx := context.Background()
if tt.bindingList != nil {
mockClient.On("List", ctx, mock.AnythingOfType("*v1alpha2.ResourceBindingList"), mock.Anything).
Return(nil).
Run(func(args mock.Arguments) {
arg := args.Get(1).(*workv1alpha2.ResourceBindingList)
*arg = *tt.bindingList
})
}
binding, err := controller.getBindingByLabel(ctx, tt.resourceLabel, tt.resourceRef)
if tt.expectedError != "" {
assert.EqualError(t, err, tt.expectedError)
} else {
assert.NoError(t, err)
assert.NotNil(t, binding)
assert.Equal(t, tt.resourceRef.Name, binding.Spec.Resource.Name)
}
mockClient.AssertExpectations(t)
})
}
}
// TestGetTargetCluster checks the getTargetCluster function's handling of various cluster states
func TestGetTargetCluster(t *testing.T) {
tests := []struct {
name string
binding *workv1alpha2.ResourceBinding
clusters map[string]*clusterv1alpha1.Cluster
getErrors map[string]error
expectedClusters []string
expectedError string
}{
{
name: "Two clusters, one ready and one not ready",
binding: &workv1alpha2.ResourceBinding{
Spec: workv1alpha2.ResourceBindingSpec{
Clusters: []workv1alpha2.TargetCluster{
{Name: "cluster1"},
{Name: "cluster2"},
},
},
},
clusters: map[string]*clusterv1alpha1.Cluster{
"cluster1": {
Status: clusterv1alpha1.ClusterStatus{
Conditions: []metav1.Condition{
{Type: clusterv1alpha1.ClusterConditionReady, Status: metav1.ConditionTrue},
},
},
},
"cluster2": {
Status: clusterv1alpha1.ClusterStatus{
Conditions: []metav1.Condition{
{Type: clusterv1alpha1.ClusterConditionReady, Status: metav1.ConditionFalse},
},
},
},
},
expectedClusters: []string{"cluster1"},
},
{
name: "Empty binding.Spec.Clusters",
binding: &workv1alpha2.ResourceBinding{
Spec: workv1alpha2.ResourceBindingSpec{
Clusters: []workv1alpha2.TargetCluster{},
},
},
expectedError: "binding has no schedulable clusters",
},
{
name: "Client.Get returns error",
binding: &workv1alpha2.ResourceBinding{
Spec: workv1alpha2.ResourceBindingSpec{
Clusters: []workv1alpha2.TargetCluster{
{Name: "cluster1"},
},
},
},
getErrors: map[string]error{
"cluster1": errors.New("get error"),
},
expectedError: "get error",
},
{
name: "Multiple ready and not ready clusters",
binding: &workv1alpha2.ResourceBinding{
Spec: workv1alpha2.ResourceBindingSpec{
Clusters: []workv1alpha2.TargetCluster{
{Name: "cluster1"},
{Name: "cluster2"},
{Name: "cluster3"},
{Name: "cluster4"},
{Name: "cluster5"},
},
},
},
clusters: map[string]*clusterv1alpha1.Cluster{
"cluster1": {
Status: clusterv1alpha1.ClusterStatus{
Conditions: []metav1.Condition{
{Type: clusterv1alpha1.ClusterConditionReady, Status: metav1.ConditionTrue},
},
},
},
"cluster2": {
Status: clusterv1alpha1.ClusterStatus{
Conditions: []metav1.Condition{
{Type: clusterv1alpha1.ClusterConditionReady, Status: metav1.ConditionFalse},
},
},
},
"cluster3": {
Status: clusterv1alpha1.ClusterStatus{
Conditions: []metav1.Condition{
{Type: clusterv1alpha1.ClusterConditionReady, Status: metav1.ConditionTrue},
},
},
},
"cluster4": {
Status: clusterv1alpha1.ClusterStatus{
Conditions: []metav1.Condition{
{Type: clusterv1alpha1.ClusterConditionReady, Status: metav1.ConditionFalse},
},
},
},
"cluster5": {
Status: clusterv1alpha1.ClusterStatus{
Conditions: []metav1.Condition{
{Type: clusterv1alpha1.ClusterConditionReady, Status: metav1.ConditionTrue},
},
},
},
},
expectedClusters: []string{"cluster1", "cluster3", "cluster5"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := new(MockClient)
controller := &FHPAController{
Client: mockClient,
}
ctx := context.Background()
for _, targetCluster := range tt.binding.Spec.Clusters {
var err error
if tt.getErrors != nil {
err = tt.getErrors[targetCluster.Name]
}
mockClient.On("Get", ctx, types.NamespacedName{Name: targetCluster.Name}, mock.AnythingOfType("*v1alpha1.Cluster"), mock.Anything).
Return(err).
Run(func(args mock.Arguments) {
if tt.clusters != nil {
arg := args.Get(2).(*clusterv1alpha1.Cluster)
*arg = *tt.clusters[targetCluster.Name]
}
})
}
clusters, err := controller.getTargetCluster(ctx, tt.binding)
if tt.expectedError != "" {
assert.EqualError(t, err, tt.expectedError)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expectedClusters, clusters)
}
mockClient.AssertExpectations(t)
for _, targetCluster := range tt.binding.Spec.Clusters {
mockClient.AssertCalled(t, "Get", ctx, types.NamespacedName{Name: targetCluster.Name}, mock.AnythingOfType("*v1alpha1.Cluster"), mock.Anything)
}
})
}
}
// TestValidateAndParseSelector ensures proper parsing and validation of selectors
func TestValidateAndParseSelector(t *testing.T) {
tests := []struct {
name string
selector string
expectedError bool
}{
{
name: "Valid selector",
selector: "app=myapp",
expectedError: false,
},
{
name: "Invalid selector",
selector: "invalid=selector=format",
expectedError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
controller := &FHPAController{
hpaSelectors: selectors.NewBiMultimap(),
hpaSelectorsMux: sync.Mutex{},
EventRecorder: &record.FakeRecorder{},
}
hpa := &autoscalingv1alpha1.FederatedHPA{
ObjectMeta: metav1.ObjectMeta{
Name: "test-hpa",
Namespace: "default",
},
}
parsedSelector, err := controller.validateAndParseSelector(hpa, tt.selector, []*corev1.Pod{})
if tt.expectedError {
assert.Error(t, err)
assert.Nil(t, parsedSelector)
assert.Contains(t, err.Error(), "couldn't convert selector into a corresponding internal selector object")
} else {
assert.NoError(t, err)
assert.NotNil(t, parsedSelector)
}
})
}
}
// TestRecordInitialRecommendation verifies correct recording of initial recommendations
func TestRecordInitialRecommendation(t *testing.T) {
tests := []struct {
name string
key string
currentReplicas int32
initialRecs []timestampedRecommendation
expectedCount int
expectedReplicas int32
}{
{
name: "New recommendation",
key: "test-hpa-1",
currentReplicas: 3,
initialRecs: nil,
expectedCount: 1,
expectedReplicas: 3,
},
{
name: "Existing recommendations",
key: "test-hpa-2",
currentReplicas: 5,
initialRecs: []timestampedRecommendation{
{recommendation: 3, timestamp: time.Now().Add(-1 * time.Minute)},
},
expectedCount: 1,
expectedReplicas: 3,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
controller := &FHPAController{
recommendations: make(map[string][]timestampedRecommendation),
}
if tt.initialRecs != nil {
controller.recommendations[tt.key] = tt.initialRecs
}
controller.recordInitialRecommendation(tt.currentReplicas, tt.key)
assert.Len(t, controller.recommendations[tt.key], tt.expectedCount)
assert.Equal(t, tt.expectedReplicas, controller.recommendations[tt.key][0].recommendation)
if tt.initialRecs == nil {
assert.WithinDuration(t, time.Now(), controller.recommendations[tt.key][0].timestamp, 2*time.Second)
} else {
assert.Equal(t, tt.initialRecs[0].timestamp, controller.recommendations[tt.key][0].timestamp)
}
})
}
}
// TestStabilizeRecommendation checks the stabilization logic for recommendations
func TestStabilizeRecommendation(t *testing.T) {
tests := []struct {
name string
key string
initialRecommendations []timestampedRecommendation
newRecommendation int32
expectedStabilized int32
expectedStoredCount int
}{
{
name: "No previous recommendations",
key: "test-hpa-1",
initialRecommendations: []timestampedRecommendation{},
newRecommendation: 5,
expectedStabilized: 5,
expectedStoredCount: 1,
},
{
name: "With previous recommendations within window",
key: "test-hpa-2",
initialRecommendations: []timestampedRecommendation{
{recommendation: 3, timestamp: time.Now().Add(-30 * time.Second)},
{recommendation: 4, timestamp: time.Now().Add(-45 * time.Second)},
},
newRecommendation: 2,
expectedStabilized: 4,
expectedStoredCount: 3,
},
{
name: "With old recommendation outside window",
key: "test-hpa-3",
initialRecommendations: []timestampedRecommendation{
{recommendation: 7, timestamp: time.Now().Add(-2 * time.Minute)},
{recommendation: 4, timestamp: time.Now().Add(-45 * time.Second)},
},
newRecommendation: 5,
expectedStabilized: 5,
expectedStoredCount: 2,
},
{
name: "All recommendations outside window",
key: "test-hpa-4",
initialRecommendations: []timestampedRecommendation{
{recommendation: 7, timestamp: time.Now().Add(-2 * time.Minute)},
{recommendation: 8, timestamp: time.Now().Add(-3 * time.Minute)},
},
newRecommendation: 3,
expectedStabilized: 3,
expectedStoredCount: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
controller := &FHPAController{
recommendations: make(map[string][]timestampedRecommendation),
DownscaleStabilisationWindow: time.Minute,
}
controller.recommendations[tt.key] = tt.initialRecommendations
stabilized := controller.stabilizeRecommendation(tt.key, tt.newRecommendation)
assert.Equal(t, tt.expectedStabilized, stabilized, "Unexpected stabilized recommendation")
assert.Len(t, controller.recommendations[tt.key], tt.expectedStoredCount, "Unexpected number of stored recommendations")
assert.True(t, containsRecommendation(controller.recommendations[tt.key], tt.newRecommendation), "New recommendation not found in stored recommendations")
oldCount := countOldRecommendations(controller.recommendations[tt.key], controller.DownscaleStabilisationWindow)
assert.LessOrEqual(t, oldCount, 1, "Too many recommendations older than stabilization window")
})
}
}
// TestNormalizeDesiredReplicas verifies the normalization of desired replicas
func TestNormalizeDesiredReplicas(t *testing.T) {
testCases := []struct {
name string
currentReplicas int32
desiredReplicas int32
minReplicas int32
maxReplicas int32
recommendations []timestampedRecommendation
expectedReplicas int32
expectedAbleToScale autoscalingv2.HorizontalPodAutoscalerConditionType
expectedAbleToScaleReason string
expectedScalingLimited corev1.ConditionStatus
expectedScalingLimitedReason string
}{
{
name: "scale up within limits",
currentReplicas: 2,
desiredReplicas: 4,
minReplicas: 1,
maxReplicas: 10,
recommendations: []timestampedRecommendation{{4, time.Now()}},
expectedReplicas: 4,
expectedAbleToScale: autoscalingv2.AbleToScale,
expectedAbleToScaleReason: "ReadyForNewScale",
expectedScalingLimited: corev1.ConditionFalse,
expectedScalingLimitedReason: "DesiredWithinRange",
},
{
name: "scale down stabilized",
currentReplicas: 5,
desiredReplicas: 3,
minReplicas: 1,
maxReplicas: 10,
recommendations: []timestampedRecommendation{{4, time.Now().Add(-1 * time.Minute)}, {3, time.Now()}},
expectedReplicas: 4,
expectedAbleToScale: autoscalingv2.AbleToScale,
expectedAbleToScaleReason: "ScaleDownStabilized",
expectedScalingLimited: corev1.ConditionFalse,
expectedScalingLimitedReason: "DesiredWithinRange",
},
{
name: "at min replicas",
currentReplicas: 2,
desiredReplicas: 1,
minReplicas: 2,
maxReplicas: 10,
recommendations: []timestampedRecommendation{{1, time.Now()}},
expectedReplicas: 2,
expectedAbleToScale: autoscalingv2.AbleToScale,
expectedAbleToScaleReason: "ReadyForNewScale",
expectedScalingLimited: corev1.ConditionTrue,
expectedScalingLimitedReason: "TooFewReplicas",
},
{
name: "at max replicas",
currentReplicas: 10,
desiredReplicas: 12,
minReplicas: 1,
maxReplicas: 10,
recommendations: []timestampedRecommendation{{12, time.Now()}},
expectedReplicas: 10,
expectedAbleToScale: autoscalingv2.AbleToScale,
expectedAbleToScaleReason: "ReadyForNewScale",
expectedScalingLimited: corev1.ConditionTrue,
expectedScalingLimitedReason: "TooManyReplicas",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
controller := &FHPAController{
recommendations: make(map[string][]timestampedRecommendation),
DownscaleStabilisationWindow: 5 * time.Minute,
}
controller.recommendations["test-hpa"] = tc.recommendations
hpa := &autoscalingv1alpha1.FederatedHPA{
Spec: autoscalingv1alpha1.FederatedHPASpec{
MinReplicas: &tc.minReplicas,
MaxReplicas: tc.maxReplicas,
},
}
normalized := controller.normalizeDesiredReplicas(hpa, "test-hpa", tc.currentReplicas, tc.desiredReplicas, tc.minReplicas)
assert.Equal(t, tc.expectedReplicas, normalized, "Unexpected normalized replicas")
ableToScaleCondition := getCondition(hpa.Status.Conditions, autoscalingv2.AbleToScale)
assert.NotNil(t, ableToScaleCondition, "AbleToScale condition not found")
assert.Equal(t, corev1.ConditionTrue, ableToScaleCondition.Status, "Unexpected AbleToScale condition status")
assert.Equal(t, tc.expectedAbleToScaleReason, ableToScaleCondition.Reason, "Unexpected AbleToScale condition reason")
scalingLimitedCondition := getCondition(hpa.Status.Conditions, autoscalingv2.ScalingLimited)
assert.NotNil(t, scalingLimitedCondition, "ScalingLimited condition not found")
assert.Equal(t, tc.expectedScalingLimited, scalingLimitedCondition.Status, "Unexpected ScalingLimited condition status")
assert.Equal(t, tc.expectedScalingLimitedReason, scalingLimitedCondition.Reason, "Unexpected ScalingLimited condition reason")
})
}
}
// TestNormalizeDesiredReplicasWithBehaviors checks replica normalization with scaling behaviors
func TestNormalizeDesiredReplicasWithBehaviors(t *testing.T) {
defaultStabilizationWindowSeconds := int32(300)
defaultSelectPolicy := autoscalingv2.MaxChangePolicySelect
tests := []struct {
name string
hpa *autoscalingv1alpha1.FederatedHPA
key string
currentReplicas int32
prenormalizedReplicas int32
expectedReplicas int32
}{
{
name: "Scale up with behavior",
hpa: createTestHPA(1, 10, &autoscalingv2.HorizontalPodAutoscalerBehavior{
ScaleUp: createTestScalingRules(&defaultStabilizationWindowSeconds, &defaultSelectPolicy, []autoscalingv2.HPAScalingPolicy{{Type: autoscalingv2.PercentScalingPolicy, Value: 200, PeriodSeconds: 60}}),
ScaleDown: createTestScalingRules(&defaultStabilizationWindowSeconds, &defaultSelectPolicy, []autoscalingv2.HPAScalingPolicy{{Type: autoscalingv2.PercentScalingPolicy, Value: 100, PeriodSeconds: 60}}),
}),
key: "test-hpa",
currentReplicas: 5,
prenormalizedReplicas: 15,
expectedReplicas: 10,
},
{
name: "Scale down with behavior",
hpa: createTestHPA(1, 10, &autoscalingv2.HorizontalPodAutoscalerBehavior{
ScaleUp: createTestScalingRules(&defaultStabilizationWindowSeconds, &defaultSelectPolicy, []autoscalingv2.HPAScalingPolicy{{Type: autoscalingv2.PercentScalingPolicy, Value: 200, PeriodSeconds: 60}}),
ScaleDown: createTestScalingRules(&defaultStabilizationWindowSeconds, &defaultSelectPolicy, []autoscalingv2.HPAScalingPolicy{{Type: autoscalingv2.PercentScalingPolicy, Value: 50, PeriodSeconds: 60}}),
}),
key: "test-hpa",
currentReplicas: 8,
prenormalizedReplicas: 2,
expectedReplicas: 4,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
controller := &FHPAController{
recommendations: make(map[string][]timestampedRecommendation),
DownscaleStabilisationWindow: 5 * time.Minute,
}
normalized := controller.normalizeDesiredReplicasWithBehaviors(tt.hpa, tt.key, tt.currentReplicas, tt.prenormalizedReplicas, *tt.hpa.Spec.MinReplicas)
assert.Equal(t, tt.expectedReplicas, normalized, "Unexpected normalized replicas")
})
}
}
// TestGetReplicasChangePerPeriod ensures correct calculation of replica changes over time
func TestGetReplicasChangePerPeriod(t *testing.T) {
now := time.Now()
tests := []struct {
name string
periodSeconds int32
scaleEvents []timestampedScaleEvent
expectedChange int32
}{
{
name: "No events",
periodSeconds: 60,
scaleEvents: []timestampedScaleEvent{},
expectedChange: 0,
},
{
name: "Single event within period",
periodSeconds: 60,
scaleEvents: []timestampedScaleEvent{
{replicaChange: 3, timestamp: now.Add(-30 * time.Second)},
},
expectedChange: 3,
},
{
name: "Multiple events, some outside period",
periodSeconds: 60,
scaleEvents: []timestampedScaleEvent{
{replicaChange: 3, timestamp: now.Add(-30 * time.Second)},
{replicaChange: 2, timestamp: now.Add(-45 * time.Second)},
{replicaChange: 1, timestamp: now.Add(-70 * time.Second)},
},
expectedChange: 5,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
change := getReplicasChangePerPeriod(tt.periodSeconds, tt.scaleEvents)
assert.Equal(t, tt.expectedChange, change, "Unexpected change in replicas")
})
}
}
// TestGetUnableComputeReplicaCountCondition verifies condition creation for compute failures
func TestGetUnableComputeReplicaCountCondition(t *testing.T) {
tests := []struct {
name string
object runtime.Object
reason string
err error
expectedEvent string
expectedMessage string
}{
{
name: "FederatedHPA with simple error",
object: createTestFederatedHPA("test-hpa", "default"),
reason: "TestReason",
err: fmt.Errorf("test error"),
expectedEvent: "Warning TestReason test error",
expectedMessage: "the HPA was unable to compute the replica count: test error",
},
{
name: "Different object type",
object: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "test-pod", Namespace: "default"}},
reason: "PodError",
err: fmt.Errorf("pod error"),
expectedEvent: "Warning PodError pod error",
expectedMessage: "the HPA was unable to compute the replica count: pod error",
},
{
name: "Complex error message",
object: createTestFederatedHPA("complex-hpa", "default"),
reason: "ComplexError",
err: fmt.Errorf("error: %v", fmt.Errorf("nested error")),
expectedEvent: "Warning ComplexError error: nested error",
expectedMessage: "the HPA was unable to compute the replica count: error: nested error",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fakeRecorder := record.NewFakeRecorder(10)
controller := &FHPAController{
EventRecorder: fakeRecorder,
}
condition := controller.getUnableComputeReplicaCountCondition(tt.object, tt.reason, tt.err)
assert.Equal(t, autoscalingv2.ScalingActive, condition.Type, "Unexpected condition type")
assert.Equal(t, corev1.ConditionFalse, condition.Status, "Unexpected condition status")
assert.Equal(t, tt.reason, condition.Reason, "Unexpected condition reason")
assert.Equal(t, tt.expectedMessage, condition.Message, "Unexpected condition message")
select {
case event := <-fakeRecorder.Events:
assert.Equal(t, tt.expectedEvent, event, "Unexpected event recorded")
case <-time.After(time.Second):
t.Error("Expected an event to be recorded, but none was")
}
})
}
}
// TestStoreScaleEvent checks proper storage of scaling events
func TestStoreScaleEvent(t *testing.T) {
tests := []struct {
name string
behavior *autoscalingv2.HorizontalPodAutoscalerBehavior
key string
prevReplicas int32
newReplicas int32
expectedUp int
expectedDown int
}{
{
name: "Scale up event",
behavior: &autoscalingv2.HorizontalPodAutoscalerBehavior{
ScaleUp: &autoscalingv2.HPAScalingRules{
StabilizationWindowSeconds: ptr.To[int32](int32(60)),
},
},
key: "test-hpa",
prevReplicas: 5,
newReplicas: 10,
expectedUp: 1,
expectedDown: 0,
},
{
name: "Scale down event",
behavior: &autoscalingv2.HorizontalPodAutoscalerBehavior{
ScaleDown: &autoscalingv2.HPAScalingRules{
StabilizationWindowSeconds: ptr.To[int32](int32(60)),
},
},
key: "test-hpa",
prevReplicas: 10,
newReplicas: 5,
expectedUp: 0,
expectedDown: 1,
},
{
name: "Nil behavior",
behavior: nil,
key: "test-hpa",
prevReplicas: 5,
newReplicas: 5,
expectedUp: 0,
expectedDown: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
controller := &FHPAController{
scaleUpEvents: make(map[string][]timestampedScaleEvent),
scaleDownEvents: make(map[string][]timestampedScaleEvent),
}
controller.storeScaleEvent(tt.behavior, tt.key, tt.prevReplicas, tt.newReplicas)
assert.Len(t, controller.scaleUpEvents[tt.key], tt.expectedUp, "Unexpected number of scale up events")
assert.Len(t, controller.scaleDownEvents[tt.key], tt.expectedDown, "Unexpected number of scale down events")
})
}
}
// TestStabilizeRecommendationWithBehaviors verifies recommendation stabilization with behaviors
func TestStabilizeRecommendationWithBehaviors(t *testing.T) {
now := time.Now()
upWindow := int32(300) // 5 minutes
downWindow := int32(600) // 10 minutes
tests := []struct {
name string
args NormalizationArg
initialRecommendations []timestampedRecommendation
expectedReplicas int32
expectedReason string
expectedMessage string
}{
{
name: "Scale up stabilized",
args: NormalizationArg{
Key: "test-hpa-1",
DesiredReplicas: 10,
CurrentReplicas: 5,
ScaleUpBehavior: &autoscalingv2.HPAScalingRules{
StabilizationWindowSeconds: &upWindow,
},
ScaleDownBehavior: &autoscalingv2.HPAScalingRules{
StabilizationWindowSeconds: &downWindow,
},
},
initialRecommendations: []timestampedRecommendation{
{recommendation: 8, timestamp: now.Add(-2 * time.Minute)},
{recommendation: 7, timestamp: now.Add(-4 * time.Minute)},
},
expectedReplicas: 7,
expectedReason: "ScaleUpStabilized",
expectedMessage: "recent recommendations were lower than current one, applying the lowest recent recommendation",
},
{
name: "Scale down stabilized",
args: NormalizationArg{
Key: "test-hpa-2",
DesiredReplicas: 3,
CurrentReplicas: 8,
ScaleUpBehavior: &autoscalingv2.HPAScalingRules{
StabilizationWindowSeconds: &upWindow,
},
ScaleDownBehavior: &autoscalingv2.HPAScalingRules{
StabilizationWindowSeconds: &downWindow,
},
},
initialRecommendations: []timestampedRecommendation{
{recommendation: 5, timestamp: now.Add(-5 * time.Minute)},
{recommendation: 4, timestamp: now.Add(-8 * time.Minute)},
},
expectedReplicas: 5,
expectedReason: "ScaleDownStabilized",
expectedMessage: "recent recommendations were higher than current one, applying the highest recent recommendation",
},
{
name: "No change needed",
args: NormalizationArg{
Key: "test-hpa-3",
DesiredReplicas: 5,
CurrentReplicas: 5,
ScaleUpBehavior: &autoscalingv2.HPAScalingRules{
StabilizationWindowSeconds: &upWindow,
},
ScaleDownBehavior: &autoscalingv2.HPAScalingRules{
StabilizationWindowSeconds: &downWindow,
},
},
initialRecommendations: []timestampedRecommendation{
{recommendation: 5, timestamp: now.Add(-1 * time.Minute)},
},
expectedReplicas: 5,
expectedReason: "ScaleUpStabilized",
expectedMessage: "recent recommendations were lower than current one, applying the lowest recent recommendation",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
controller := &FHPAController{
recommendations: make(map[string][]timestampedRecommendation),
}
controller.recommendations[tt.args.Key] = tt.initialRecommendations
gotReplicas, gotReason, gotMessage := controller.stabilizeRecommendationWithBehaviors(tt.args)
assert.Equal(t, tt.expectedReplicas, gotReplicas, "Unexpected stabilized replicas")
assert.Equal(t, tt.expectedReason, gotReason, "Unexpected stabilization reason")
assert.Equal(t, tt.expectedMessage, gotMessage, "Unexpected stabilization message")
storedRecommendations := controller.recommendations[tt.args.Key]
assert.True(t, containsRecommendation(storedRecommendations, tt.args.DesiredReplicas), "New recommendation not found in stored recommendations")
assert.Len(t, storedRecommendations, len(tt.initialRecommendations)+1, "Unexpected number of stored recommendations")
})
}
}
// TestConvertDesiredReplicasWithBehaviorRate verifies replica conversion with behavior rates
func TestConvertDesiredReplicasWithBehaviorRate(t *testing.T) {
tests := []struct {
name string
args NormalizationArg
scaleUpEvents []timestampedScaleEvent
scaleDownEvents []timestampedScaleEvent
expectedReplicas int32
expectedReason string
expectedMessage string
}{
{
name: "Scale up within limits",
args: NormalizationArg{
Key: "test-hpa",
ScaleUpBehavior: &autoscalingv2.HPAScalingRules{
Policies: []autoscalingv2.HPAScalingPolicy{
{Type: autoscalingv2.PercentScalingPolicy, Value: 200, PeriodSeconds: 60},
},
},
MinReplicas: 1,
MaxReplicas: 10,
CurrentReplicas: 5,
DesiredReplicas: 8,
},
expectedReplicas: 8,
expectedReason: "DesiredWithinRange",
expectedMessage: "the desired count is within the acceptable range",
},
{
name: "Scale down within limits",
args: NormalizationArg{
Key: "test-hpa",
ScaleDownBehavior: &autoscalingv2.HPAScalingRules{
Policies: []autoscalingv2.HPAScalingPolicy{
{Type: autoscalingv2.PercentScalingPolicy, Value: 100, PeriodSeconds: 60},
},
},
MinReplicas: 1,
MaxReplicas: 10,
CurrentReplicas: 5,
DesiredReplicas: 3,
},
expectedReplicas: 3,
expectedReason: "DesiredWithinRange",
expectedMessage: "the desired count is within the acceptable range",
},
{
name: "Scale up beyond MaxReplicas",
args: NormalizationArg{
Key: "test-hpa",
ScaleUpBehavior: &autoscalingv2.HPAScalingRules{
Policies: []autoscalingv2.HPAScalingPolicy{
{Type: autoscalingv2.PercentScalingPolicy, Value: 200, PeriodSeconds: 60},
},
},
MinReplicas: 1,
MaxReplicas: 10,
CurrentReplicas: 8,
DesiredReplicas: 12,
},
expectedReplicas: 10,
expectedReason: "TooManyReplicas",
expectedMessage: "the desired replica count is more than the maximum replica count",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
controller := &FHPAController{
scaleUpEvents: make(map[string][]timestampedScaleEvent),
scaleDownEvents: make(map[string][]timestampedScaleEvent),
}
controller.scaleUpEvents[tt.args.Key] = tt.scaleUpEvents
controller.scaleDownEvents[tt.args.Key] = tt.scaleDownEvents
replicas, reason, message := controller.convertDesiredReplicasWithBehaviorRate(tt.args)
assert.Equal(t, tt.expectedReplicas, replicas, "Unexpected number of replicas")
assert.Equal(t, tt.expectedReason, reason, "Unexpected reason")
assert.Equal(t, tt.expectedMessage, message, "Unexpected message")
})
}
}
// TestConvertDesiredReplicasWithRules checks replica conversion using basic rules
func TestConvertDesiredReplicasWithRules(t *testing.T) {
tests := []struct {
name string
currentReplicas int32
desiredReplicas int32
hpaMinReplicas int32
hpaMaxReplicas int32
expectedReplicas int32
expectedCondition string
expectedReason string
}{
{
name: "Desired within range",
currentReplicas: 5,
desiredReplicas: 7,
hpaMinReplicas: 3,
hpaMaxReplicas: 10,
expectedReplicas: 7,
expectedCondition: "DesiredWithinRange",
expectedReason: "the desired count is within the acceptable range",
},
{
name: "Desired below min",
currentReplicas: 5,
desiredReplicas: 2,
hpaMinReplicas: 3,
hpaMaxReplicas: 10,
expectedReplicas: 3,
expectedCondition: "TooFewReplicas",
expectedReason: "the desired replica count is less than the minimum replica count",
},
{
name: "Desired above max",
currentReplicas: 5,
desiredReplicas: 15,
hpaMinReplicas: 3,
hpaMaxReplicas: 10,
expectedReplicas: 10,
expectedCondition: "TooManyReplicas",
expectedReason: "the desired replica count is more than the maximum replica count",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
replicas, condition, reason := convertDesiredReplicasWithRules(tt.currentReplicas, tt.desiredReplicas, tt.hpaMinReplicas, tt.hpaMaxReplicas)
assert.Equal(t, tt.expectedReplicas, replicas, "Unexpected number of replicas")
assert.Equal(t, tt.expectedCondition, condition, "Unexpected condition")
assert.Equal(t, tt.expectedReason, reason, "Unexpected reason")
})
}
}
// TestCalculateScaleUpLimitWithScalingRules verifies scale-up limit calculation with rules
func TestCalculateScaleUpLimit(t *testing.T) {
tests := []struct {
name string
currentReplicas int32
expectedLimit int32
}{
{
name: "Small scale up",
currentReplicas: 1,
expectedLimit: 4,
},
{
name: "Medium scale up",
currentReplicas: 10,
expectedLimit: 20,
},
{
name: "Large scale up",
currentReplicas: 100,
expectedLimit: 200,
},
{
name: "Zero replicas",
currentReplicas: 0,
expectedLimit: 4,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
limit := calculateScaleUpLimit(tt.currentReplicas)
assert.Equal(t, tt.expectedLimit, limit, "Unexpected scale up limit")
})
}
}
// TestMarkScaleEventsOutdated ensures proper marking of outdated scale events
func TestMarkScaleEventsOutdated(t *testing.T) {
now := time.Now()
tests := []struct {
name string
scaleEvents []timestampedScaleEvent
longestPolicyPeriod int32
expectedOutdated []bool
}{
{
name: "All events within period",
scaleEvents: []timestampedScaleEvent{
{timestamp: now.Add(-30 * time.Second)},
{timestamp: now.Add(-60 * time.Second)},
},
longestPolicyPeriod: 120,
expectedOutdated: []bool{false, false},
},
{
name: "Some events outdated",
scaleEvents: []timestampedScaleEvent{
{timestamp: now.Add(-30 * time.Second)},
{timestamp: now.Add(-90 * time.Second)},
{timestamp: now.Add(-150 * time.Second)},
},
longestPolicyPeriod: 120,
expectedOutdated: []bool{false, false, true},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
markScaleEventsOutdated(tt.scaleEvents, tt.longestPolicyPeriod)
for i, event := range tt.scaleEvents {
assert.Equal(t, tt.expectedOutdated[i], event.outdated, "Unexpected outdated status for event %d", i)
}
})
}
}
// TestGetLongestPolicyPeriod checks retrieval of the longest policy period
func TestGetLongestPolicyPeriod(t *testing.T) {
tests := []struct {
name string
scalingRules *autoscalingv2.HPAScalingRules
expectedPeriod int32
}{
{
name: "Single policy",
scalingRules: &autoscalingv2.HPAScalingRules{
Policies: []autoscalingv2.HPAScalingPolicy{
{PeriodSeconds: 60},
},
},
expectedPeriod: 60,
},
{
name: "Multiple policies",
scalingRules: &autoscalingv2.HPAScalingRules{
Policies: []autoscalingv2.HPAScalingPolicy{
{PeriodSeconds: 60},
{PeriodSeconds: 120},
{PeriodSeconds: 30},
},
},
expectedPeriod: 120,
},
{
name: "No policies",
scalingRules: &autoscalingv2.HPAScalingRules{
Policies: []autoscalingv2.HPAScalingPolicy{},
},
expectedPeriod: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
period := getLongestPolicyPeriod(tt.scalingRules)
assert.Equal(t, tt.expectedPeriod, period, "Unexpected longest policy period")
})
}
}
// TestCalculateScaleUpLimitWithScalingRules verifies scale-up limit calculation with rules
func TestCalculateScaleUpLimitWithScalingRules(t *testing.T) {
baseTime := time.Now()
disabledPolicy := autoscalingv2.DisabledPolicySelect
minChangePolicy := autoscalingv2.MinChangePolicySelect
tests := []struct {
name string
currentReplicas int32
scaleUpEvents []timestampedScaleEvent
scaleDownEvents []timestampedScaleEvent
scalingRules *autoscalingv2.HPAScalingRules
expectedLimit int32
}{
{
name: "No previous events",
currentReplicas: 5,
scaleUpEvents: []timestampedScaleEvent{},
scaleDownEvents: []timestampedScaleEvent{},
scalingRules: &autoscalingv2.HPAScalingRules{
Policies: []autoscalingv2.HPAScalingPolicy{
{Type: autoscalingv2.PodsScalingPolicy, Value: 4, PeriodSeconds: 60},
},
},
expectedLimit: 9,
},
{
name: "With previous scale up event",
currentReplicas: 5,
scaleUpEvents: []timestampedScaleEvent{
{replicaChange: 2, timestamp: baseTime.Add(-30 * time.Second)},
},
scaleDownEvents: []timestampedScaleEvent{},
scalingRules: &autoscalingv2.HPAScalingRules{
Policies: []autoscalingv2.HPAScalingPolicy{
{Type: autoscalingv2.PodsScalingPolicy, Value: 4, PeriodSeconds: 60},
},
},
expectedLimit: 7,
},
{
name: "Disabled policy",
currentReplicas: 5,
scaleUpEvents: []timestampedScaleEvent{},
scaleDownEvents: []timestampedScaleEvent{},
scalingRules: &autoscalingv2.HPAScalingRules{
SelectPolicy: &disabledPolicy,
Policies: []autoscalingv2.HPAScalingPolicy{
{Type: autoscalingv2.PodsScalingPolicy, Value: 4, PeriodSeconds: 60},
},
},
expectedLimit: 5,
},
{
name: "MinChange policy",
currentReplicas: 5,
scaleUpEvents: []timestampedScaleEvent{},
scaleDownEvents: []timestampedScaleEvent{},
scalingRules: &autoscalingv2.HPAScalingRules{
SelectPolicy: &minChangePolicy,
Policies: []autoscalingv2.HPAScalingPolicy{
{Type: autoscalingv2.PodsScalingPolicy, Value: 4, PeriodSeconds: 60},
{Type: autoscalingv2.PodsScalingPolicy, Value: 2, PeriodSeconds: 60},
},
},
expectedLimit: 7,
},
{
name: "Percent scaling policy",
currentReplicas: 10,
scaleUpEvents: []timestampedScaleEvent{},
scaleDownEvents: []timestampedScaleEvent{},
scalingRules: &autoscalingv2.HPAScalingRules{
Policies: []autoscalingv2.HPAScalingPolicy{
{Type: autoscalingv2.PercentScalingPolicy, Value: 50, PeriodSeconds: 60},
},
},
expectedLimit: 15,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
limit := calculateScaleUpLimitWithScalingRules(tt.currentReplicas, tt.scaleUpEvents, tt.scaleDownEvents, tt.scalingRules)
assert.Equal(t, tt.expectedLimit, limit, "Unexpected scale up limit")
})
}
}
// TestCalculateScaleDownLimitWithBehaviors checks scale-down limit calculation with behaviors
func TestCalculateScaleDownLimitWithBehaviors(t *testing.T) {
baseTime := time.Now()
disabledPolicy := autoscalingv2.DisabledPolicySelect
minChangePolicy := autoscalingv2.MinChangePolicySelect
tests := []struct {
name string
currentReplicas int32
scaleUpEvents []timestampedScaleEvent
scaleDownEvents []timestampedScaleEvent
scalingRules *autoscalingv2.HPAScalingRules
expectedLimit int32
}{
{
name: "No previous events",
currentReplicas: 10,
scaleUpEvents: []timestampedScaleEvent{},
scaleDownEvents: []timestampedScaleEvent{},
scalingRules: &autoscalingv2.HPAScalingRules{
Policies: []autoscalingv2.HPAScalingPolicy{
{Type: autoscalingv2.PercentScalingPolicy, Value: 20, PeriodSeconds: 60},
},
},
expectedLimit: 8,
},
{
name: "With previous scale down event",
currentReplicas: 10,
scaleUpEvents: []timestampedScaleEvent{},
scaleDownEvents: []timestampedScaleEvent{
{replicaChange: 1, timestamp: baseTime.Add(-30 * time.Second)},
},
scalingRules: &autoscalingv2.HPAScalingRules{
Policies: []autoscalingv2.HPAScalingPolicy{
{Type: autoscalingv2.PercentScalingPolicy, Value: 20, PeriodSeconds: 60},
},
},
expectedLimit: 8,
},
{
name: "Multiple policies",
currentReplicas: 100,
scaleUpEvents: []timestampedScaleEvent{},
scaleDownEvents: []timestampedScaleEvent{},
scalingRules: &autoscalingv2.HPAScalingRules{
Policies: []autoscalingv2.HPAScalingPolicy{
{Type: autoscalingv2.PercentScalingPolicy, Value: 10, PeriodSeconds: 60},
{Type: autoscalingv2.PodsScalingPolicy, Value: 5, PeriodSeconds: 60},
},
},
expectedLimit: 90,
},
{
name: "Disabled policy",
currentReplicas: 10,
scaleUpEvents: []timestampedScaleEvent{},
scaleDownEvents: []timestampedScaleEvent{},
scalingRules: &autoscalingv2.HPAScalingRules{
SelectPolicy: &disabledPolicy,
Policies: []autoscalingv2.HPAScalingPolicy{
{Type: autoscalingv2.PercentScalingPolicy, Value: 20, PeriodSeconds: 60},
},
},
expectedLimit: 10,
},
{
name: "MinChange policy",
currentReplicas: 100,
scaleUpEvents: []timestampedScaleEvent{},
scaleDownEvents: []timestampedScaleEvent{},
scalingRules: &autoscalingv2.HPAScalingRules{
SelectPolicy: &minChangePolicy,
Policies: []autoscalingv2.HPAScalingPolicy{
{Type: autoscalingv2.PercentScalingPolicy, Value: 10, PeriodSeconds: 60},
{Type: autoscalingv2.PodsScalingPolicy, Value: 15, PeriodSeconds: 60},
},
},
expectedLimit: 90,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
limit := calculateScaleDownLimitWithBehaviors(tt.currentReplicas, tt.scaleUpEvents, tt.scaleDownEvents, tt.scalingRules)
assert.Equal(t, tt.expectedLimit, limit, "Unexpected scale down limit")
})
}
}
// TestSetCurrentReplicasInStatus verifies setting of current replicas in HPA status
func TestSetCurrentReplicasInStatus(t *testing.T) {
controller := &FHPAController{}
hpa := &autoscalingv1alpha1.FederatedHPA{
Status: autoscalingv2.HorizontalPodAutoscalerStatus{
DesiredReplicas: 5,
CurrentMetrics: []autoscalingv2.MetricStatus{
{Type: autoscalingv2.ResourceMetricSourceType},
},
},
}
controller.setCurrentReplicasInStatus(hpa, 3)
assert.Equal(t, int32(3), hpa.Status.CurrentReplicas)
assert.Equal(t, int32(5), hpa.Status.DesiredReplicas)
assert.Len(t, hpa.Status.CurrentMetrics, 1)
assert.Nil(t, hpa.Status.LastScaleTime)
}
// TestSetStatus ensures correct status setting for FederatedHPA
func TestSetStatus(t *testing.T) {
tests := []struct {
name string
currentReplicas int32
desiredReplicas int32
metricStatuses []autoscalingv2.MetricStatus
rescale bool
initialLastScale *metav1.Time
}{
{
name: "Update without rescale",
currentReplicas: 3,
desiredReplicas: 5,
metricStatuses: []autoscalingv2.MetricStatus{
{Type: autoscalingv2.ResourceMetricSourceType},
},
rescale: false,
initialLastScale: nil,
},
{
name: "Update with rescale",
currentReplicas: 3,
desiredReplicas: 5,
metricStatuses: []autoscalingv2.MetricStatus{
{Type: autoscalingv2.ResourceMetricSourceType},
},
rescale: true,
initialLastScale: &metav1.Time{Time: time.Now().Add(-1 * time.Hour)},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
controller := &FHPAController{}
hpa := &autoscalingv1alpha1.FederatedHPA{
Status: autoscalingv2.HorizontalPodAutoscalerStatus{
LastScaleTime: tt.initialLastScale,
Conditions: []autoscalingv2.HorizontalPodAutoscalerCondition{
{Type: autoscalingv2.ScalingActive},
},
},
}
controller.setStatus(hpa, tt.currentReplicas, tt.desiredReplicas, tt.metricStatuses, tt.rescale)
assert.Equal(t, tt.currentReplicas, hpa.Status.CurrentReplicas)
assert.Equal(t, tt.desiredReplicas, hpa.Status.DesiredReplicas)
assert.Equal(t, tt.metricStatuses, hpa.Status.CurrentMetrics)
assert.Len(t, hpa.Status.Conditions, 1)
if tt.rescale {
assert.NotNil(t, hpa.Status.LastScaleTime)
assert.True(t, hpa.Status.LastScaleTime.After(time.Now().Add(-1*time.Second)))
} else {
assert.Equal(t, tt.initialLastScale, hpa.Status.LastScaleTime)
}
})
}
}
// TestSetCondition verifies proper condition setting in FederatedHPA
func TestSetCondition(t *testing.T) {
tests := []struct {
name string
initialHPA *autoscalingv1alpha1.FederatedHPA
conditionType autoscalingv2.HorizontalPodAutoscalerConditionType
status corev1.ConditionStatus
reason string
message string
args []interface{}
expectedLength int
checkIndex int
}{
{
name: "Add new condition",
initialHPA: &autoscalingv1alpha1.FederatedHPA{},
conditionType: autoscalingv2.ScalingActive,
status: corev1.ConditionTrue,
reason: "TestReason",
message: "Test message",
expectedLength: 1,
checkIndex: 0,
},
{
name: "Update existing condition",
initialHPA: &autoscalingv1alpha1.FederatedHPA{
Status: autoscalingv2.HorizontalPodAutoscalerStatus{
Conditions: []autoscalingv2.HorizontalPodAutoscalerCondition{
{
Type: autoscalingv2.ScalingActive,
Status: corev1.ConditionFalse,
},
},
},
},
conditionType: autoscalingv2.ScalingActive,
status: corev1.ConditionTrue,
reason: "UpdatedReason",
message: "Updated message",
expectedLength: 1,
checkIndex: 0,
},
{
name: "Add condition with formatted message",
initialHPA: &autoscalingv1alpha1.FederatedHPA{},
conditionType: autoscalingv2.AbleToScale,
status: corev1.ConditionTrue,
reason: "FormattedReason",
message: "Formatted message: %d",
args: []interface{}{42},
expectedLength: 1,
checkIndex: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setCondition(tt.initialHPA, tt.conditionType, tt.status, tt.reason, tt.message, tt.args...)
assert.Len(t, tt.initialHPA.Status.Conditions, tt.expectedLength, "Unexpected number of conditions")
condition := tt.initialHPA.Status.Conditions[tt.checkIndex]
assert.Equal(t, tt.conditionType, condition.Type, "Unexpected condition type")
assert.Equal(t, tt.status, condition.Status, "Unexpected condition status")
assert.Equal(t, tt.reason, condition.Reason, "Unexpected condition reason")
expectedMessage := tt.message
if len(tt.args) > 0 {
expectedMessage = fmt.Sprintf(tt.message, tt.args...)
}
assert.Equal(t, expectedMessage, condition.Message, "Unexpected condition message")
assert.False(t, condition.LastTransitionTime.IsZero(), "LastTransitionTime should be set")
})
}
}
// TestSetConditionInList ensures proper condition setting in a list of conditions
func TestSetConditionInList(t *testing.T) {
tests := []struct {
name string
inputList []autoscalingv2.HorizontalPodAutoscalerCondition
conditionType autoscalingv2.HorizontalPodAutoscalerConditionType
status corev1.ConditionStatus
reason string
message string
args []interface{}
expectedLength int
checkIndex int
}{
{
name: "Add new condition",
inputList: []autoscalingv2.HorizontalPodAutoscalerCondition{},
conditionType: autoscalingv2.ScalingActive,
status: corev1.ConditionTrue,
reason: "TestReason",
message: "Test message",
expectedLength: 1,
checkIndex: 0,
},
{
name: "Update existing condition",
inputList: []autoscalingv2.HorizontalPodAutoscalerCondition{
{
Type: autoscalingv2.ScalingActive,
Status: corev1.ConditionFalse,
},
},
conditionType: autoscalingv2.ScalingActive,
status: corev1.ConditionTrue,
reason: "UpdatedReason",
message: "Updated message",
expectedLength: 1,
checkIndex: 0,
},
{
name: "Add condition with formatted message",
inputList: []autoscalingv2.HorizontalPodAutoscalerCondition{
{
Type: autoscalingv2.ScalingActive,
Status: corev1.ConditionTrue,
},
},
conditionType: autoscalingv2.AbleToScale,
status: corev1.ConditionTrue,
reason: "FormattedReason",
message: "Formatted message: %d",
args: []interface{}{42},
expectedLength: 2,
checkIndex: 1,
},
{
name: "Update condition without changing status",
inputList: []autoscalingv2.HorizontalPodAutoscalerCondition{
{
Type: autoscalingv2.ScalingActive,
Status: corev1.ConditionTrue,
LastTransitionTime: metav1.Now(),
},
},
conditionType: autoscalingv2.ScalingActive,
status: corev1.ConditionTrue,
reason: "NewReason",
message: "New message",
expectedLength: 1,
checkIndex: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := setConditionInList(tt.inputList, tt.conditionType, tt.status, tt.reason, tt.message, tt.args...)
assert.Len(t, result, tt.expectedLength, "Unexpected length of result list")
condition := result[tt.checkIndex]
assert.Equal(t, tt.conditionType, condition.Type, "Unexpected condition type")
assert.Equal(t, tt.status, condition.Status, "Unexpected condition status")
assert.Equal(t, tt.reason, condition.Reason, "Unexpected condition reason")
expectedMessage := tt.message
if len(tt.args) > 0 {
expectedMessage = fmt.Sprintf(tt.message, tt.args...)
}
assert.Equal(t, expectedMessage, condition.Message, "Unexpected condition message")
if tt.name == "Update existing condition" {
assert.False(t, condition.LastTransitionTime.IsZero(), "LastTransitionTime should be set")
}
if tt.name == "Update condition without changing status" {
assert.Equal(t, tt.inputList[0].LastTransitionTime, condition.LastTransitionTime, "LastTransitionTime should not change")
}
})
}
}
// Helper functions
func getCondition(conditions []autoscalingv2.HorizontalPodAutoscalerCondition, conditionType autoscalingv2.HorizontalPodAutoscalerConditionType) *autoscalingv2.HorizontalPodAutoscalerCondition {
for _, condition := range conditions {
if condition.Type == conditionType {
return &condition
}
}
return nil
}
func createTestFederatedHPA(name, namespace string) *autoscalingv1alpha1.FederatedHPA {
return &autoscalingv1alpha1.FederatedHPA{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
},
}
}
func createTestHPA(minReplicas, maxReplicas int32, behavior *autoscalingv2.HorizontalPodAutoscalerBehavior) *autoscalingv1alpha1.FederatedHPA {
return &autoscalingv1alpha1.FederatedHPA{
Spec: autoscalingv1alpha1.FederatedHPASpec{
MinReplicas: &minReplicas,
MaxReplicas: maxReplicas,
Behavior: behavior,
},
}
}
func createTestScalingRules(stabilizationWindowSeconds *int32, selectPolicy *autoscalingv2.ScalingPolicySelect, policies []autoscalingv2.HPAScalingPolicy) *autoscalingv2.HPAScalingRules {
return &autoscalingv2.HPAScalingRules{
StabilizationWindowSeconds: stabilizationWindowSeconds,
SelectPolicy: selectPolicy,
Policies: policies,
}
}
func countOldRecommendations(recommendations []timestampedRecommendation, window time.Duration) int {
count := 0
now := time.Now()
for _, rec := range recommendations {
if rec.timestamp.Before(now.Add(-window)) {
count++
}
}
return count
}
func containsRecommendation(slice []timestampedRecommendation, recommendation int32) bool {
for _, item := range slice {
if item.recommendation == recommendation {
return true
}
}
return false
}