karmada/pkg/controllers/federatedhpa/replica_calculator_test.go

1479 lines
41 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"
"fmt"
"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/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/util/sets"
metricsclient "github.com/karmada-io/karmada/pkg/controllers/federatedhpa/metrics"
)
// MockQueryClient implements a mock for the metrics client
type MockQueryClient struct {
mock.Mock
}
func (m *MockQueryClient) GetResourceMetric(ctx context.Context, resource corev1.ResourceName, namespace string, selector labels.Selector, container string) (metricsclient.PodMetricsInfo, time.Time, error) {
args := m.Called(ctx, resource, namespace, selector, container)
return args.Get(0).(metricsclient.PodMetricsInfo), args.Get(1).(time.Time), args.Error(2)
}
func (m *MockQueryClient) GetRawMetric(metricName string, namespace string, selector labels.Selector, metricSelector labels.Selector) (metricsclient.PodMetricsInfo, time.Time, error) {
args := m.Called(metricName, namespace, selector, metricSelector)
return args.Get(0).(metricsclient.PodMetricsInfo), args.Get(1).(time.Time), args.Error(2)
}
func (m *MockQueryClient) GetObjectMetric(metricName string, namespace string, objectRef *autoscalingv2.CrossVersionObjectReference, metricSelector labels.Selector) (int64, time.Time, error) {
args := m.Called(metricName, namespace, objectRef, metricSelector)
return args.Get(0).(int64), args.Get(1).(time.Time), args.Error(2)
}
func (m *MockQueryClient) GetExternalMetric(metricName string, namespace string, selector labels.Selector) ([]int64, time.Time, error) {
args := m.Called(metricName, namespace, selector)
return args.Get(0).([]int64), args.Get(1).(time.Time), args.Error(2)
}
// TestNewReplicaCalculator verifies the creation of a new ReplicaCalculator
func TestNewReplicaCalculator(t *testing.T) {
const (
defaultTolerance = 0.1
defaultCPUInitPeriod = 5 * time.Minute
defaultDelayInitReadinessStatus = 30 * time.Second
)
tests := []struct {
name string
tolerance float64
cpuInitPeriod time.Duration
delayInitReadinessStatus time.Duration
}{
{
name: "Default values",
tolerance: defaultTolerance,
cpuInitPeriod: defaultCPUInitPeriod,
delayInitReadinessStatus: defaultDelayInitReadinessStatus,
},
{
name: "Zero values",
tolerance: 0,
cpuInitPeriod: 0,
delayInitReadinessStatus: 0,
},
{
name: "Custom values",
tolerance: 0.2,
cpuInitPeriod: 10 * time.Minute,
delayInitReadinessStatus: 1 * time.Minute,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &MockQueryClient{}
calculator := NewReplicaCalculator(mockClient,
tt.tolerance,
tt.cpuInitPeriod,
tt.delayInitReadinessStatus)
assert.NotNil(t, calculator, "Calculator should not be nil")
assert.Equal(t, mockClient, calculator.metricsClient, "Metrics client should match")
assert.Equal(t, tt.tolerance, calculator.tolerance, "Tolerance should match")
assert.Equal(t, tt.cpuInitPeriod, calculator.cpuInitializationPeriod, "CPU initialization period should match")
assert.Equal(t, tt.delayInitReadinessStatus, calculator.delayOfInitialReadinessStatus, "Delay of initial readiness status should match")
})
}
}
// TestGetResourceReplicas checks the calculation of resource-based replicas
func TestGetResourceReplicas(t *testing.T) {
const (
defaultNamespace = "default"
defaultContainer = ""
defaultCalibration = 1.0
defaultTolerance = 0.1
)
mockTime := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
testCases := []struct {
name string
currentReplicas int32
targetUtilization int32
pods []*corev1.Pod
metrics metricsclient.PodMetricsInfo
expectedReplicas int32
expectedUtilization int32
expectedRawUtilization int64
calibration float64
tolerance float64
expectError bool
}{
{
name: "Scale up",
currentReplicas: 2,
targetUtilization: 50,
pods: []*corev1.Pod{
createPod("pod1", 100, 200),
createPod("pod2", 100, 200),
},
metrics: metricsclient.PodMetricsInfo{
"pod1": {Value: 150},
"pod2": {Value: 150},
},
expectedReplicas: 6,
expectedUtilization: 150,
expectedRawUtilization: 150,
calibration: defaultCalibration,
tolerance: defaultTolerance,
},
{
name: "Scale down",
currentReplicas: 4,
targetUtilization: 50,
pods: []*corev1.Pod{
createPod("pod1", 100, 200),
createPod("pod2", 100, 200),
createPod("pod3", 100, 200),
createPod("pod4", 100, 200),
},
metrics: metricsclient.PodMetricsInfo{
"pod1": {Value: 50},
"pod2": {Value: 50},
"pod3": {Value: 50},
"pod4": {Value: 50},
},
expectedReplicas: 4,
expectedUtilization: 50,
expectedRawUtilization: 50,
calibration: defaultCalibration,
tolerance: defaultTolerance,
},
{
name: "No change (within tolerance)",
currentReplicas: 2,
targetUtilization: 50,
pods: []*corev1.Pod{
createPod("pod1", 100, 200),
createPod("pod2", 100, 200),
},
metrics: metricsclient.PodMetricsInfo{
"pod1": {Value: 52},
"pod2": {Value: 48},
},
expectedReplicas: 2,
expectedUtilization: 50,
expectedRawUtilization: 50,
calibration: defaultCalibration,
tolerance: defaultTolerance,
},
{
name: "Scale up with unready pods",
currentReplicas: 3,
targetUtilization: 50,
pods: []*corev1.Pod{
createPod("pod1", 100, 200),
createPod("pod2", 100, 200),
createUnreadyPod("pod3", 100, 200),
},
metrics: metricsclient.PodMetricsInfo{
"pod1": {Value: 150},
"pod2": {Value: 150},
},
expectedReplicas: 6,
expectedUtilization: 150,
expectedRawUtilization: 150,
calibration: defaultCalibration,
tolerance: defaultTolerance,
},
{
name: "Scale with calibration",
currentReplicas: 2,
targetUtilization: 50,
pods: []*corev1.Pod{
createPod("pod1", 100, 200),
createPod("pod2", 100, 200),
},
metrics: metricsclient.PodMetricsInfo{
"pod1": {Value: 150},
"pod2": {Value: 150},
},
expectedReplicas: 12,
expectedUtilization: 150,
expectedRawUtilization: 150,
calibration: 0.5,
tolerance: defaultTolerance,
},
{
name: "Error: No pods",
currentReplicas: 2,
targetUtilization: 50,
pods: []*corev1.Pod{},
metrics: metricsclient.PodMetricsInfo{},
expectError: true,
},
{
name: "Error: No metrics for ready pods",
currentReplicas: 2,
targetUtilization: 50,
pods: []*corev1.Pod{
createPod("pod1", 100, 200),
createPod("pod2", 100, 200),
},
metrics: metricsclient.PodMetricsInfo{},
expectError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
mockClient := &MockQueryClient{}
calculator := NewReplicaCalculator(mockClient,
tc.tolerance,
5*time.Minute,
30*time.Second)
mockClient.On("GetResourceMetric", mock.Anything, corev1.ResourceCPU, defaultNamespace, labels.Everything(), defaultContainer).
Return(tc.metrics, mockTime, nil).Once()
replicas, utilization, rawUtilization, timestamp, err := calculator.GetResourceReplicas(
context.Background(), tc.currentReplicas, tc.targetUtilization, corev1.ResourceCPU,
defaultNamespace, labels.Everything(), defaultContainer, tc.pods, tc.calibration)
if tc.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expectedReplicas, replicas)
assert.Equal(t, tc.expectedUtilization, utilization)
assert.Equal(t, tc.expectedRawUtilization, rawUtilization)
assert.Equal(t, mockTime, timestamp)
}
mockClient.AssertExpectations(t)
})
}
}
// TestGetRawResourceReplicas verifies the calculation of raw resource-based replicas
func TestGetRawResourceReplicas(t *testing.T) {
const (
defaultNamespace = "default"
defaultContainer = ""
cpuInitializationPeriod = 5 * time.Minute
delayOfInitialReadinessStatus = 30 * time.Second
)
mockTime := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
testCases := []struct {
name string
currentReplicas int32
targetUsage int64
resource corev1.ResourceName
namespace string
selector labels.Selector
container string
podList []*corev1.Pod
metrics metricsclient.PodMetricsInfo
calibration float64
expectedReplicas int32
expectedUsage int64
expectError bool
}{
{
name: "Scale up based on raw metrics",
currentReplicas: 2,
targetUsage: 100,
resource: corev1.ResourceCPU,
namespace: defaultNamespace,
selector: labels.Everything(),
container: defaultContainer,
podList: []*corev1.Pod{
createPod("pod1", 100, 200),
createPod("pod2", 100, 200),
},
metrics: metricsclient.PodMetricsInfo{
"pod1": {Value: 150},
"pod2": {Value: 150},
},
calibration: 1.0,
expectedReplicas: 3,
expectedUsage: 150,
expectError: false,
},
{
name: "Scale down based on raw metrics",
currentReplicas: 4,
targetUsage: 100,
resource: corev1.ResourceCPU,
namespace: defaultNamespace,
selector: labels.Everything(),
container: defaultContainer,
podList: []*corev1.Pod{
createPod("pod1", 100, 200),
createPod("pod2", 100, 200),
createPod("pod3", 100, 200),
createPod("pod4", 100, 200),
},
metrics: metricsclient.PodMetricsInfo{
"pod1": {Value: 50},
"pod2": {Value: 50},
"pod3": {Value: 50},
"pod4": {Value: 50},
},
calibration: 1.0,
expectedReplicas: 2,
expectedUsage: 50,
expectError: false,
},
{
name: "No change (at target usage)",
currentReplicas: 2,
targetUsage: 100,
resource: corev1.ResourceCPU,
namespace: defaultNamespace,
selector: labels.Everything(),
container: defaultContainer,
podList: []*corev1.Pod{
createPod("pod1", 100, 200),
createPod("pod2", 100, 200),
},
metrics: metricsclient.PodMetricsInfo{
"pod1": {Value: 100},
"pod2": {Value: 100},
},
calibration: 1.0,
expectedReplicas: 2,
expectedUsage: 100,
expectError: false,
},
{
name: "Scale with calibration",
currentReplicas: 2,
targetUsage: 100,
resource: corev1.ResourceCPU,
namespace: defaultNamespace,
selector: labels.Everything(),
container: defaultContainer,
podList: []*corev1.Pod{
createPod("pod1", 100, 200),
createPod("pod2", 100, 200),
},
metrics: metricsclient.PodMetricsInfo{
"pod1": {Value: 150},
"pod2": {Value: 150},
},
calibration: 0.8,
expectedReplicas: 4,
expectedUsage: 150,
expectError: false,
},
{
name: "Error: No pods",
currentReplicas: 2,
targetUsage: 100,
resource: corev1.ResourceCPU,
namespace: defaultNamespace,
selector: labels.Everything(),
container: defaultContainer,
podList: []*corev1.Pod{},
metrics: metricsclient.PodMetricsInfo{},
calibration: 1.0,
expectError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
mockClient := &MockQueryClient{}
calculator := NewReplicaCalculator(
mockClient,
0.1,
cpuInitializationPeriod,
delayOfInitialReadinessStatus,
)
mockClient.On("GetResourceMetric",
mock.Anything,
tc.resource,
tc.namespace,
tc.selector,
tc.container,
).Return(tc.metrics, mockTime, nil).Once()
replicas, usage, timestamp, err := calculator.GetRawResourceReplicas(
context.Background(),
tc.currentReplicas,
tc.targetUsage,
tc.resource,
tc.namespace,
tc.selector,
tc.container,
tc.podList,
tc.calibration,
)
if tc.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expectedReplicas, replicas)
assert.Equal(t, tc.expectedUsage, usage)
assert.Equal(t, mockTime, timestamp)
}
mockClient.AssertExpectations(t)
})
}
}
// TestGetMetricReplicas checks the calculation of metric-based replicas
func TestGetMetricReplicas(t *testing.T) {
const (
defaultNamespace = "default"
cpuInitializationPeriod = 5 * time.Minute
delayOfInitialReadinessStatus = 30 * time.Second
)
mockTime := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
testCases := []struct {
name string
currentReplicas int32
targetUsage int64
metricName string
namespace string
selector labels.Selector
metricSelector labels.Selector
podList []*corev1.Pod
metrics metricsclient.PodMetricsInfo
calibration float64
expectedReplicas int32
expectedUsage int64
expectError bool
}{
{
name: "Scale up based on custom metrics",
currentReplicas: 2,
targetUsage: 10,
metricName: "custom_metric",
namespace: defaultNamespace,
selector: labels.Everything(),
metricSelector: labels.Everything(),
podList: []*corev1.Pod{
createPod("pod1", 100, 200),
createPod("pod2", 100, 200),
},
metrics: metricsclient.PodMetricsInfo{
"pod1": {Value: 15},
"pod2": {Value: 15},
},
calibration: 1.0,
expectedReplicas: 3,
expectedUsage: 15,
expectError: false,
},
{
name: "Scale down based on custom metrics",
currentReplicas: 4,
targetUsage: 20,
metricName: "custom_metric",
namespace: defaultNamespace,
selector: labels.Everything(),
metricSelector: labels.Everything(),
podList: []*corev1.Pod{
createPod("pod1", 100, 200),
createPod("pod2", 100, 200),
createPod("pod3", 100, 200),
createPod("pod4", 100, 200),
},
metrics: metricsclient.PodMetricsInfo{
"pod1": {Value: 10},
"pod2": {Value: 10},
"pod3": {Value: 10},
"pod4": {Value: 10},
},
calibration: 1.0,
expectedReplicas: 2,
expectedUsage: 10,
expectError: false,
},
{
name: "No change (at target usage)",
currentReplicas: 2,
targetUsage: 15,
metricName: "custom_metric",
namespace: defaultNamespace,
selector: labels.Everything(),
metricSelector: labels.Everything(),
podList: []*corev1.Pod{
createPod("pod1", 100, 200),
createPod("pod2", 100, 200),
},
metrics: metricsclient.PodMetricsInfo{
"pod1": {Value: 15},
"pod2": {Value: 15},
},
calibration: 1.0,
expectedReplicas: 2,
expectedUsage: 15,
expectError: false,
},
{
name: "Scale with calibration",
currentReplicas: 2,
targetUsage: 10,
metricName: "custom_metric",
namespace: defaultNamespace,
selector: labels.Everything(),
metricSelector: labels.Everything(),
podList: []*corev1.Pod{
createPod("pod1", 100, 200),
createPod("pod2", 100, 200),
},
metrics: metricsclient.PodMetricsInfo{
"pod1": {Value: 15},
"pod2": {Value: 15},
},
calibration: 0.8,
expectedReplicas: 4,
expectedUsage: 15,
expectError: false,
},
{
name: "Error: No metrics",
currentReplicas: 2,
targetUsage: 10,
metricName: "custom_metric",
namespace: defaultNamespace,
selector: labels.Everything(),
metricSelector: labels.Everything(),
podList: []*corev1.Pod{
createPod("pod1", 100, 200),
createPod("pod2", 100, 200),
},
metrics: metricsclient.PodMetricsInfo{},
calibration: 1.0,
expectError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
mockClient := &MockQueryClient{}
calculator := NewReplicaCalculator(
mockClient,
0.1,
cpuInitializationPeriod,
delayOfInitialReadinessStatus,
)
mockClient.On("GetRawMetric",
tc.metricName,
tc.namespace,
tc.selector,
tc.metricSelector,
).Return(tc.metrics, mockTime, nil).Once()
replicas, usage, timestamp, err := calculator.GetMetricReplicas(
tc.currentReplicas,
tc.targetUsage,
tc.metricName,
tc.namespace,
tc.selector,
tc.metricSelector,
tc.podList,
tc.calibration,
)
if tc.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expectedReplicas, replicas)
assert.Equal(t, tc.expectedUsage, usage)
assert.Equal(t, mockTime, timestamp)
}
mockClient.AssertExpectations(t)
})
}
}
// TestCalcPlainMetricReplicas verifies the calculation of plain metric-based replicas
func TestCalcPlainMetricReplicas(t *testing.T) {
const (
cpuInitializationPeriod = 5 * time.Minute
delayOfInitialReadinessStatus = 30 * time.Second
defaultTolerance = 0.1
)
testCases := []struct {
name string
metrics metricsclient.PodMetricsInfo
currentReplicas int32
targetUsage int64
resource corev1.ResourceName
podList []*corev1.Pod
calibration float64
expectedReplicas int32
expectedUsage int64
expectError bool
}{
{
name: "Scale up",
metrics: metricsclient.PodMetricsInfo{
"pod1": {Value: 15},
"pod2": {Value: 15},
},
currentReplicas: 2,
targetUsage: 10,
resource: corev1.ResourceCPU,
podList: []*corev1.Pod{
createPod("pod1", 100, 200),
createPod("pod2", 100, 200),
},
calibration: 1.0,
expectedReplicas: 3,
expectedUsage: 15,
expectError: false,
},
{
name: "Scale down",
metrics: metricsclient.PodMetricsInfo{
"pod1": {Value: 5},
"pod2": {Value: 5},
"pod3": {Value: 5},
"pod4": {Value: 5},
},
currentReplicas: 4,
targetUsage: 10,
resource: corev1.ResourceCPU,
podList: []*corev1.Pod{
createPod("pod1", 100, 200),
createPod("pod2", 100, 200),
createPod("pod3", 100, 200),
createPod("pod4", 100, 200),
},
calibration: 1.0,
expectedReplicas: 2,
expectedUsage: 5,
expectError: false,
},
{
name: "No change (within tolerance)",
metrics: metricsclient.PodMetricsInfo{
"pod1": {Value: 10},
"pod2": {Value: 10},
},
currentReplicas: 2,
targetUsage: 10,
resource: corev1.ResourceCPU,
podList: []*corev1.Pod{
createPod("pod1", 100, 200),
createPod("pod2", 100, 200),
},
calibration: 1.0,
expectedReplicas: 2,
expectedUsage: 10,
expectError: false,
},
{
name: "Scale up with unready pods",
metrics: metricsclient.PodMetricsInfo{
"pod1": {Value: 15},
"pod2": {Value: 15},
},
currentReplicas: 3,
targetUsage: 10,
resource: corev1.ResourceCPU,
podList: []*corev1.Pod{
createPod("pod1", 100, 200),
createPod("pod2", 100, 200),
createUnreadyPod("pod3", 100, 200),
},
calibration: 1.0,
expectedReplicas: 3,
expectedUsage: 15,
expectError: false,
},
{
name: "Scale down with missing pods",
metrics: metricsclient.PodMetricsInfo{
"pod1": {Value: 5},
"pod2": {Value: 5},
},
currentReplicas: 3,
targetUsage: 10,
resource: corev1.ResourceCPU,
podList: []*corev1.Pod{
createPod("pod1", 100, 200),
createPod("pod2", 100, 200),
createPod("pod3", 100, 200),
},
calibration: 1.0,
expectedReplicas: 2,
expectedUsage: 5,
expectError: false,
},
{
name: "Scale with calibration",
metrics: metricsclient.PodMetricsInfo{
"pod1": {Value: 15},
"pod2": {Value: 15},
},
currentReplicas: 2,
targetUsage: 10,
resource: corev1.ResourceCPU,
podList: []*corev1.Pod{
createPod("pod1", 100, 200),
createPod("pod2", 100, 200),
},
calibration: 0.8,
expectedReplicas: 4,
expectedUsage: 15,
expectError: false,
},
{
name: "Error: No pods",
metrics: metricsclient.PodMetricsInfo{},
currentReplicas: 2,
targetUsage: 10,
resource: corev1.ResourceCPU,
podList: []*corev1.Pod{},
calibration: 1.0,
expectError: true,
},
{
name: "Error: No metrics for ready pods",
metrics: metricsclient.PodMetricsInfo{},
currentReplicas: 2,
targetUsage: 10,
resource: corev1.ResourceCPU,
podList: []*corev1.Pod{
createUnreadyPod("pod1", 100, 200),
createUnreadyPod("pod2", 100, 200),
},
calibration: 1.0,
expectError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
calculator := NewReplicaCalculator(
nil, // metrics client not needed for this test
defaultTolerance,
cpuInitializationPeriod,
delayOfInitialReadinessStatus,
)
replicas, usage, err := calculator.calcPlainMetricReplicas(
tc.metrics,
tc.currentReplicas,
tc.targetUsage,
tc.resource,
tc.podList,
tc.calibration,
)
if tc.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expectedReplicas, replicas)
assert.Equal(t, tc.expectedUsage, usage)
}
})
}
}
// Helper function to create an unready pod
func createUnreadyPod(name string, request, limit int64) *corev1.Pod {
pod := createPod(name, request, limit)
pod.Status.Conditions = []corev1.PodCondition{
{
Type: corev1.PodReady,
Status: corev1.ConditionFalse,
},
}
return pod
}
func TestGetObjectMetricReplicas(t *testing.T) {
mockClient := &MockQueryClient{}
calculator := NewReplicaCalculator(mockClient, 0.1, 5*time.Minute, 30*time.Second)
testCases := []struct {
name string
currentReplicas int32
targetUsage int64
metricName string
namespace string
objectRef *autoscalingv2.CrossVersionObjectReference
metricSelector labels.Selector
podList []*corev1.Pod
objectMetric int64
calibration float64
expectedReplicas int32
expectedError bool
}{
{
name: "Scale up based on object metrics",
currentReplicas: 2,
targetUsage: 10,
metricName: "queue_length",
namespace: "default",
objectRef: &autoscalingv2.CrossVersionObjectReference{Kind: "Service", Name: "my-svc"},
metricSelector: labels.Everything(),
podList: []*corev1.Pod{
createPod("pod1", 100, 200),
createPod("pod2", 100, 200),
},
objectMetric: 30,
calibration: 1.0,
expectedReplicas: 6,
expectedError: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
mockClient.On("GetObjectMetric", tc.metricName, tc.namespace, tc.objectRef, tc.metricSelector).Return(tc.objectMetric, time.Now(), nil).Once()
replicas, _, _, err := calculator.GetObjectMetricReplicas(tc.currentReplicas, tc.targetUsage, tc.metricName, tc.namespace, tc.objectRef, tc.metricSelector, tc.podList, tc.calibration)
if tc.expectedError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expectedReplicas, replicas)
}
})
}
}
// TestGetObjectPerPodMetricReplicas verifies the calculation of per-pod object metric-based replicas
func TestGetObjectPerPodMetricReplicas(t *testing.T) {
const (
defaultNamespace = "default"
cpuInitializationPeriod = 5 * time.Minute
delayOfInitialReadinessStatus = 30 * time.Second
defaultTolerance = 0.1
)
defaultObjectRef := &autoscalingv2.CrossVersionObjectReference{Kind: "Service", Name: "my-svc"}
testCases := []struct {
name string
statusReplicas int32
targetAverageUsage int64
metricName string
namespace string
objectRef *autoscalingv2.CrossVersionObjectReference
metricSelector labels.Selector
objectMetric int64
calibration float64
tolerance float64
expectedReplicas int32
expectedUsage int64
expectError bool
}{
{
name: "Scale up based on per-pod object metrics",
statusReplicas: 2,
targetAverageUsage: 10,
metricName: "requests_per_pod",
namespace: defaultNamespace,
objectRef: defaultObjectRef,
metricSelector: labels.Everything(),
objectMetric: 30,
calibration: 1.0,
tolerance: defaultTolerance,
expectedReplicas: 3,
expectedUsage: 15,
expectError: false,
},
{
name: "Scale down based on per-pod object metrics",
statusReplicas: 4,
targetAverageUsage: 20,
metricName: "requests_per_pod",
namespace: defaultNamespace,
objectRef: defaultObjectRef,
metricSelector: labels.Everything(),
objectMetric: 60,
calibration: 1.0,
tolerance: defaultTolerance,
expectedReplicas: 3,
expectedUsage: 15,
expectError: false,
},
{
name: "No change due to tolerance",
statusReplicas: 3,
targetAverageUsage: 10,
metricName: "requests_per_pod",
namespace: defaultNamespace,
objectRef: defaultObjectRef,
metricSelector: labels.Everything(),
objectMetric: 32, // Just within tolerance (10% of 30)
calibration: 1.0,
tolerance: defaultTolerance,
expectedReplicas: 3,
expectedUsage: 11,
expectError: false,
},
{
name: "Scale with calibration",
statusReplicas: 2,
targetAverageUsage: 10,
metricName: "requests_per_pod",
namespace: defaultNamespace,
objectRef: defaultObjectRef,
metricSelector: labels.Everything(),
objectMetric: 30,
calibration: 0.5,
tolerance: defaultTolerance,
expectedReplicas: 12,
expectedUsage: 15,
expectError: false,
},
{
name: "Error getting metric",
statusReplicas: 2,
targetAverageUsage: 10,
metricName: "requests_per_pod",
namespace: defaultNamespace,
objectRef: defaultObjectRef,
metricSelector: labels.Everything(),
objectMetric: 0,
calibration: 1.0,
tolerance: defaultTolerance,
expectError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
mockClient := &MockQueryClient{}
calculator := NewReplicaCalculator(
mockClient,
tc.tolerance,
cpuInitializationPeriod,
delayOfInitialReadinessStatus,
)
if tc.expectError {
mockClient.On("GetObjectMetric", tc.metricName, tc.namespace, tc.objectRef, tc.metricSelector).
Return(int64(0), time.Time{}, fmt.Errorf("metric not available")).Once()
} else {
mockClient.On("GetObjectMetric", tc.metricName, tc.namespace, tc.objectRef, tc.metricSelector).
Return(tc.objectMetric, time.Now(), nil).Once()
}
replicas, usage, timestamp, err := calculator.GetObjectPerPodMetricReplicas(
tc.statusReplicas,
tc.targetAverageUsage,
tc.metricName,
tc.namespace,
tc.objectRef,
tc.metricSelector,
tc.calibration,
)
if tc.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expectedReplicas, replicas, "Unexpected replica count")
assert.Equal(t, tc.expectedUsage, usage, "Unexpected usage value")
assert.False(t, timestamp.IsZero(), "Timestamp should not be zero")
}
mockClient.AssertExpectations(t)
})
}
}
// TestGetUsageRatioReplicaCount checks the calculation of usage ratio-based replica count
func TestGetUsageRatioReplicaCount(t *testing.T) {
const (
cpuInitializationPeriod = 5 * time.Minute
delayOfInitialReadinessStatus = 30 * time.Second
defaultTolerance = 0.1
)
testCases := []struct {
name string
currentReplicas int32
usageRatio float64
podList []*corev1.Pod
calibration float64
tolerance float64
expectedReplicas int32
expectError bool
}{
{
name: "Scale up",
currentReplicas: 2,
usageRatio: 1.5,
podList: []*corev1.Pod{createPod("pod1", 100, 200), createPod("pod2", 100, 200)},
calibration: 1.0,
tolerance: defaultTolerance,
expectedReplicas: 3,
expectError: false,
},
{
name: "Scale down",
currentReplicas: 4,
usageRatio: 0.5,
podList: []*corev1.Pod{
createPod("pod1", 100, 200),
createPod("pod2", 100, 200),
createPod("pod3", 100, 200),
createPod("pod4", 100, 200),
},
calibration: 1.0,
tolerance: defaultTolerance,
expectedReplicas: 2,
expectError: false,
},
{
name: "No change due to tolerance",
currentReplicas: 3,
usageRatio: 1.05,
podList: []*corev1.Pod{
createPod("pod1", 100, 200),
createPod("pod2", 100, 200),
createPod("pod3", 100, 200),
},
calibration: 1.0,
tolerance: defaultTolerance,
expectedReplicas: 3,
expectError: false,
},
{
name: "Scale to zero",
currentReplicas: 0,
usageRatio: 0.0,
podList: []*corev1.Pod{},
calibration: 1.0,
tolerance: defaultTolerance,
expectedReplicas: 0,
expectError: false,
},
{
name: "Scale from zero",
currentReplicas: 0,
usageRatio: 1.5,
podList: []*corev1.Pod{},
calibration: 1.0,
tolerance: defaultTolerance,
expectedReplicas: 2,
expectError: false,
},
{
name: "Scale with calibration",
currentReplicas: 2,
usageRatio: 1.5,
podList: []*corev1.Pod{createPod("pod1", 100, 200), createPod("pod2", 100, 200)},
calibration: 0.5,
tolerance: defaultTolerance,
expectedReplicas: 6,
expectError: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
mockClient := &MockQueryClient{}
calculator := NewReplicaCalculator(
mockClient,
tc.tolerance,
cpuInitializationPeriod,
delayOfInitialReadinessStatus,
)
replicas, timestamp, err := calculator.getUsageRatioReplicaCount(
tc.currentReplicas,
tc.usageRatio,
tc.podList,
tc.calibration,
)
assert.NoError(t, err, "Unexpected error: %v", err)
assert.Equal(t, tc.expectedReplicas, replicas, "Unexpected replica count")
assert.True(t, timestamp.IsZero(), "Expected zero timestamp, but got: %v", timestamp)
})
}
}
// TestGetReadyPodsCount verifies the counting of ready pods
func TestGetReadyPodsCount(t *testing.T) {
testCases := []struct {
name string
podList []*corev1.Pod
expectedCount int64
expectError bool
}{
{
name: "All pods ready",
podList: []*corev1.Pod{
createPod("pod1", 100, 200),
createPod("pod2", 100, 200),
createPod("pod3", 100, 200),
},
expectedCount: 3,
expectError: false,
},
{
name: "Mixed ready and unready pods",
podList: []*corev1.Pod{
createPod("pod1", 100, 200),
createUnreadyPod("pod2", 100, 200),
createPod("pod3", 100, 200),
createUnreadyPod("pod4", 100, 200),
},
expectedCount: 2,
expectError: false,
},
{
name: "All pods unready",
podList: []*corev1.Pod{
createUnreadyPod("pod1", 100, 200),
createUnreadyPod("pod2", 100, 200),
},
expectedCount: 0,
expectError: false,
},
{
name: "Empty pod list",
podList: []*corev1.Pod{},
expectedCount: 0,
expectError: true,
},
{
name: "Pods with different phases",
podList: []*corev1.Pod{
createPod("pod1", 100, 200),
createPodWithPhase("pod2", 100, 200, corev1.PodPending),
createPodWithPhase("pod3", 100, 200, corev1.PodSucceeded),
createPodWithPhase("pod4", 100, 200, corev1.PodFailed),
},
expectedCount: 1,
expectError: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
calculator := &ReplicaCalculator{} // Don't need to initialize other fields for this test
count, err := calculator.getReadyPodsCount(tc.podList)
if tc.expectError {
assert.Error(t, err, "Expected an error, but got none")
} else {
assert.NoError(t, err, "Unexpected error: %v", err)
assert.Equal(t, tc.expectedCount, count, "Unexpected ready pod count")
}
})
}
}
// TestGroupPods checks the grouping of pods based on their status and metrics
func TestGroupPods(t *testing.T) {
now := time.Now()
testCases := []struct {
name string
pods []*corev1.Pod
metrics metricsclient.PodMetricsInfo
resource corev1.ResourceName
cpuInitializationPeriod time.Duration
delayOfInitialReadinessStatus time.Duration
expectedReadyCount int
expectedUnreadyPods []string
expectedMissingPods []string
expectedIgnoredPods []string
}{
{
name: "All pods ready and with metrics",
pods: []*corev1.Pod{
createPod("pod1", 100, 200),
createPod("pod2", 100, 200),
},
metrics: metricsclient.PodMetricsInfo{
"pod1": {Value: 150, Window: time.Minute, Timestamp: now},
"pod2": {Value: 150, Window: time.Minute, Timestamp: now},
},
resource: corev1.ResourceCPU,
cpuInitializationPeriod: 5 * time.Minute,
delayOfInitialReadinessStatus: 30 * time.Second,
expectedReadyCount: 2,
},
{
name: "One pod unready (Pending)",
pods: []*corev1.Pod{
createPod("pod1", 100, 200),
createPodWithPhase("pod2", 100, 200, corev1.PodPending),
},
metrics: metricsclient.PodMetricsInfo{
"pod1": {Value: 150, Window: time.Minute, Timestamp: now},
"pod2": {Value: 150, Window: time.Minute, Timestamp: now},
},
resource: corev1.ResourceCPU,
cpuInitializationPeriod: 5 * time.Minute,
delayOfInitialReadinessStatus: 30 * time.Second,
expectedReadyCount: 1,
expectedUnreadyPods: []string{"pod2"},
},
{
name: "One pod missing metrics",
pods: []*corev1.Pod{
createPod("pod1", 100, 200),
createPod("pod2", 100, 200),
},
metrics: metricsclient.PodMetricsInfo{
"pod1": {Value: 150, Window: time.Minute, Timestamp: now},
},
resource: corev1.ResourceCPU,
cpuInitializationPeriod: 5 * time.Minute,
delayOfInitialReadinessStatus: 30 * time.Second,
expectedReadyCount: 1,
expectedMissingPods: []string{"pod2"},
},
{
name: "One pod ignored (Failed)",
pods: []*corev1.Pod{
createPod("pod1", 100, 200),
createPodWithPhase("pod2", 100, 200, corev1.PodFailed),
},
metrics: metricsclient.PodMetricsInfo{
"pod1": {Value: 150, Window: time.Minute, Timestamp: now},
"pod2": {Value: 150, Window: time.Minute, Timestamp: now},
},
resource: corev1.ResourceCPU,
cpuInitializationPeriod: 5 * time.Minute,
delayOfInitialReadinessStatus: 30 * time.Second,
expectedReadyCount: 1,
expectedIgnoredPods: []string{"pod2"},
},
{
name: "Pod within CPU initialization period",
pods: []*corev1.Pod{
createPodWithStartTime("pod1", 100, 200, now.Add(-2*time.Minute)),
},
metrics: metricsclient.PodMetricsInfo{
"pod1": {Value: 150, Window: time.Minute, Timestamp: now},
},
resource: corev1.ResourceCPU,
cpuInitializationPeriod: 5 * time.Minute,
delayOfInitialReadinessStatus: 30 * time.Second,
expectedReadyCount: 1,
expectedUnreadyPods: []string{},
},
{
name: "Non-CPU resource",
pods: []*corev1.Pod{
createPod("pod1", 100, 200),
createUnreadyPod("pod2", 100, 200),
},
metrics: metricsclient.PodMetricsInfo{
"pod1": {Value: 150, Window: time.Minute, Timestamp: now},
"pod2": {Value: 150, Window: time.Minute, Timestamp: now},
},
resource: corev1.ResourceMemory,
cpuInitializationPeriod: 5 * time.Minute,
delayOfInitialReadinessStatus: 30 * time.Second,
expectedReadyCount: 2,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
readyCount, unreadyPods, missingPods, ignoredPods := groupPods(tc.pods, tc.metrics, tc.resource, tc.cpuInitializationPeriod, tc.delayOfInitialReadinessStatus)
assert.Equal(t, tc.expectedReadyCount, readyCount, "Ready pod count mismatch")
assertSetContains(t, unreadyPods, tc.expectedUnreadyPods, "Unready pods mismatch")
assertSetContains(t, missingPods, tc.expectedMissingPods, "Missing pods mismatch")
assertSetContains(t, ignoredPods, tc.expectedIgnoredPods, "Ignored pods mismatch")
})
}
}
// TestCalculatePodRequests verifies the calculation of pod resource requests
func TestCalculatePodRequests(t *testing.T) {
testCases := []struct {
name string
pods []*corev1.Pod
container string
resource corev1.ResourceName
expectedResult map[string]int64
expectedError bool
}{
{
name: "Calculate CPU requests for all containers",
pods: []*corev1.Pod{
createPod("pod1", 100, 200),
createPod("pod2", 200, 300),
},
container: "",
resource: corev1.ResourceCPU,
expectedResult: map[string]int64{"pod1": 100, "pod2": 200},
expectedError: false,
},
{
name: "Calculate memory requests for specific container",
pods: []*corev1.Pod{
{
ObjectMeta: metav1.ObjectMeta{Name: "pod1"},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "container1",
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceMemory: resource.MustParse("100Mi"),
},
},
},
{
Name: "container2",
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceMemory: resource.MustParse("200Mi"),
},
},
},
},
},
},
},
container: "container2",
resource: corev1.ResourceMemory,
expectedResult: map[string]int64{"pod1": 209715200000},
expectedError: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, err := calculatePodRequests(tc.pods, tc.container, tc.resource)
if tc.expectedError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expectedResult, result)
}
})
}
}
// TestRemoveMetricsForPods checks the removal of metrics for specified pods
func TestRemoveMetricsForPods(t *testing.T) {
metrics := metricsclient.PodMetricsInfo{
"pod1": {Value: 100},
"pod2": {Value: 200},
"pod3": {Value: 300},
}
podsToRemove := sets.New("pod1", "pod3")
removeMetricsForPods(metrics, podsToRemove)
assert.Equal(t, 1, len(metrics))
assert.Contains(t, metrics, "pod2")
assert.NotContains(t, metrics, "pod1")
assert.NotContains(t, metrics, "pod3")
}
// Helper Functions
// Helper function to create a pod
func createPod(name string, request, limit int64) *corev1.Pod {
return &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Labels: map[string]string{
"test": "true",
},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "test-container",
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceCPU: *resource.NewMilliQuantity(request, resource.DecimalSI),
},
Limits: corev1.ResourceList{
corev1.ResourceCPU: *resource.NewMilliQuantity(limit, resource.DecimalSI),
},
},
},
},
},
Status: corev1.PodStatus{
Phase: corev1.PodRunning,
Conditions: []corev1.PodCondition{
{
Type: corev1.PodReady,
Status: corev1.ConditionTrue,
},
},
StartTime: &metav1.Time{Time: time.Now().Add(-1 * time.Hour)},
},
}
}
// Helper function to create a pod with a specific phase
func createPodWithPhase(name string, request, limit int64, phase corev1.PodPhase) *corev1.Pod {
pod := createPod(name, request, limit)
pod.Status.Phase = phase
return pod
}
// Helper function to assert that a set contains expected elements
func assertSetContains(t *testing.T, set sets.Set[string], expected []string, message string) {
assert.Equal(t, len(expected), set.Len(), message)
for _, item := range expected {
assert.True(t, set.Has(item), fmt.Sprintf("%s: %s not found", message, item))
}
}
// Helper function to create a pod with a specific start time
func createPodWithStartTime(name string, request, limit int64, startTime time.Time) *corev1.Pod {
pod := createPod(name, request, limit)
pod.Status.StartTime = &metav1.Time{Time: startTime}
return pod
}