diff --git a/pkg/controllers/cronfederatedhpa/cronfederatedhpa_handler_test.go b/pkg/controllers/cronfederatedhpa/cronfederatedhpa_handler_test.go new file mode 100644 index 000000000000..11fc45f6551a --- /dev/null +++ b/pkg/controllers/cronfederatedhpa/cronfederatedhpa_handler_test.go @@ -0,0 +1,298 @@ +/* +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 cronfederatedhpa + +import ( + "testing" + "time" + + "github.com/go-co-op/gocron" + "github.com/stretchr/testify/assert" + autoscalingv2 "k8s.io/api/autoscaling/v2" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/tools/record" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + autoscalingv1alpha1 "github.com/karmada-io/karmada/pkg/apis/autoscaling/v1alpha1" + "github.com/karmada-io/karmada/pkg/util/helper" +) + +func TestCronFHPAScaleTargetRefUpdates(t *testing.T) { + tests := []struct { + name string + cronFHPAKey string + initialTarget autoscalingv2.CrossVersionObjectReference + updatedTarget autoscalingv2.CrossVersionObjectReference + expectedUpdate bool + }{ + { + name: "New scale target", + cronFHPAKey: "default/new-cronhpa", + initialTarget: autoscalingv2.CrossVersionObjectReference{}, // Empty for new target + updatedTarget: autoscalingv2.CrossVersionObjectReference{ + Kind: "Deployment", + Name: "test-deployment", + APIVersion: "apps/v1", + }, + expectedUpdate: false, + }, + { + name: "Same scale target", + cronFHPAKey: "default/test-cronhpa", + initialTarget: autoscalingv2.CrossVersionObjectReference{ + Kind: "Deployment", + Name: "test-deployment", + APIVersion: "apps/v1", + }, + updatedTarget: autoscalingv2.CrossVersionObjectReference{ + Kind: "Deployment", + Name: "test-deployment", + APIVersion: "apps/v1", + }, + expectedUpdate: false, + }, + { + name: "Different scale target", + cronFHPAKey: "default/test-cronhpa", + initialTarget: autoscalingv2.CrossVersionObjectReference{ + Kind: "Deployment", + Name: "test-deployment", + APIVersion: "apps/v1", + }, + updatedTarget: autoscalingv2.CrossVersionObjectReference{ + Kind: "StatefulSet", + Name: "test-statefulset", + APIVersion: "apps/v1", + }, + expectedUpdate: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := NewCronHandler(fake.NewClientBuilder().Build(), record.NewFakeRecorder(100)) + + // Empty initialTarget will be skipped + if tt.initialTarget != (autoscalingv2.CrossVersionObjectReference{}) { + updated := handler.CronFHPAScaleTargetRefUpdates(tt.cronFHPAKey, tt.initialTarget) + assert.False(t, updated, "Initial target setting should return false") + } + + updated := handler.CronFHPAScaleTargetRefUpdates(tt.cronFHPAKey, tt.updatedTarget) + assert.Equal(t, tt.expectedUpdate, updated, "Unexpected result for %s", tt.name) + }) + } +} + +func TestAddCronExecutorIfNotExist(t *testing.T) { + handler := NewCronHandler(fake.NewClientBuilder().Build(), record.NewFakeRecorder(100)) + + cronFHPAKey := "default/test-cronhpa" + + // Adding new executor + handler.AddCronExecutorIfNotExist(cronFHPAKey) + assert.Contains(t, handler.cronExecutorMap, cronFHPAKey, "Executor should be added") + + // Adding existing executor + originalLen := len(handler.cronExecutorMap) + handler.AddCronExecutorIfNotExist(cronFHPAKey) + assert.Equal(t, originalLen, len(handler.cronExecutorMap), "Existing executor should not be added again") +} + +func TestRuleCronExecutorExists(t *testing.T) { + handler := NewCronHandler(fake.NewClientBuilder().Build(), record.NewFakeRecorder(100)) + + cronFHPAKey := "default/test-cronhpa" + ruleName := "test-rule" + + // non-existent executor + _, exists := handler.RuleCronExecutorExists(cronFHPAKey, ruleName) + assert.False(t, exists, "Non-existent executor should return false") + + // Add an executor + handler.AddCronExecutorIfNotExist(cronFHPAKey) + handler.cronExecutorMap[cronFHPAKey][ruleName] = RuleCron{ + CronFederatedHPARule: autoscalingv1alpha1.CronFederatedHPARule{ + Name: ruleName, + }, + } + + rule, exists := handler.RuleCronExecutorExists(cronFHPAKey, ruleName) + assert.True(t, exists, "Existing executor should return true") + assert.Equal(t, ruleName, rule.Name, "Returned rule should match the added rule") +} + +func TestStopRuleExecutor(t *testing.T) { + handler := NewCronHandler(fake.NewClientBuilder().Build(), record.NewFakeRecorder(100)) + + cronFHPAKey := "default/test-cronhpa" + ruleName := "test-rule" + + handler.cronExecutorMap = make(map[string]map[string]RuleCron) + handler.cronExecutorMap[cronFHPAKey] = make(map[string]RuleCron) + handler.cronExecutorMap[cronFHPAKey][ruleName] = RuleCron{ + Scheduler: gocron.NewScheduler(time.UTC), + } + _, err := handler.cronExecutorMap[cronFHPAKey][ruleName].Scheduler.Every(1).Minute().Do(func() {}) + assert.NoError(t, err) + handler.cronExecutorMap[cronFHPAKey][ruleName].Scheduler.StartAsync() + + assert.True(t, handler.cronExecutorMap[cronFHPAKey][ruleName].Scheduler.IsRunning()) + + handler.StopRuleExecutor(cronFHPAKey, ruleName) + + assert.NotContains(t, handler.cronExecutorMap[cronFHPAKey], ruleName) +} + +func TestStopCronFHPAExecutor(t *testing.T) { + handler := NewCronHandler(fake.NewClientBuilder().Build(), record.NewFakeRecorder(100)) + + cronFHPAKey := "default/test-cronhpa" + ruleName1 := "test-rule-1" + ruleName2 := "test-rule-2" + + handler.cronExecutorMap = make(map[string]map[string]RuleCron) + handler.cronExecutorMap[cronFHPAKey] = make(map[string]RuleCron) + handler.cronExecutorMap[cronFHPAKey][ruleName1] = RuleCron{ + Scheduler: gocron.NewScheduler(time.UTC), + } + handler.cronExecutorMap[cronFHPAKey][ruleName2] = RuleCron{ + Scheduler: gocron.NewScheduler(time.UTC), + } + + _, err := handler.cronExecutorMap[cronFHPAKey][ruleName1].Scheduler.Every(1).Minute().Do(func() {}) + assert.NoError(t, err) + + _, err = handler.cronExecutorMap[cronFHPAKey][ruleName2].Scheduler.Every(1).Minute().Do(func() {}) + assert.NoError(t, err) + + handler.cronExecutorMap[cronFHPAKey][ruleName1].Scheduler.StartAsync() + handler.cronExecutorMap[cronFHPAKey][ruleName2].Scheduler.StartAsync() + + assert.True(t, handler.cronExecutorMap[cronFHPAKey][ruleName1].Scheduler.IsRunning()) + assert.True(t, handler.cronExecutorMap[cronFHPAKey][ruleName2].Scheduler.IsRunning()) + + handler.StopCronFHPAExecutor(cronFHPAKey) + + assert.NotContains(t, handler.cronExecutorMap, cronFHPAKey) +} + +func TestCreateCronJobForExecutor(t *testing.T) { + handler := NewCronHandler(fake.NewClientBuilder().Build(), record.NewFakeRecorder(100)) + + cronFHPA := &autoscalingv1alpha1.CronFederatedHPA{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cronhpa", + Namespace: "default", + }, + } + + tests := []struct { + name string + rule autoscalingv1alpha1.CronFederatedHPARule + timeZone *string + wantErr bool + }{ + { + name: "Valid rule without time zone", + rule: autoscalingv1alpha1.CronFederatedHPARule{ + Name: "test-rule", + Schedule: "*/5 * * * *", + }, + timeZone: nil, + wantErr: false, + }, + { + name: "Valid rule with valid time zone", + rule: autoscalingv1alpha1.CronFederatedHPARule{ + Name: "test-rule-tz", + Schedule: "*/5 * * * *", + }, + timeZone: ptr.To[string]("America/New_York"), + wantErr: false, + }, + { + name: "Valid rule with invalid time zone", + rule: autoscalingv1alpha1.CronFederatedHPARule{ + Name: "test-rule-invalid-tz", + Schedule: "*/5 * * * *", + }, + timeZone: ptr.To[string]("Invalid/TimeZone"), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cronFHPAKey := helper.GetCronFederatedHPAKey(cronFHPA) + handler.cronExecutorMap = make(map[string]map[string]RuleCron) + handler.cronExecutorMap[cronFHPAKey] = make(map[string]RuleCron) + + tt.rule.TimeZone = tt.timeZone + err := handler.CreateCronJobForExecutor(cronFHPA, tt.rule) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Contains(t, handler.cronExecutorMap[cronFHPAKey], tt.rule.Name) + ruleCron := handler.cronExecutorMap[cronFHPAKey][tt.rule.Name] + assert.NotNil(t, ruleCron.Scheduler) + assert.True(t, ruleCron.Scheduler.IsRunning()) + } + + // Clean up + if !tt.wantErr { + handler.cronExecutorMap[cronFHPAKey][tt.rule.Name].Scheduler.Stop() + } + }) + } +} + +func TestGetRuleNextExecuteTime(t *testing.T) { + handler := NewCronHandler(fake.NewClientBuilder().Build(), record.NewFakeRecorder(100)) + + cronFHPA := &autoscalingv1alpha1.CronFederatedHPA{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cronhpa", + Namespace: "default", + }, + } + + rule := autoscalingv1alpha1.CronFederatedHPARule{ + Name: "test-rule", + Schedule: "*/5 * * * *", + } + + cronFHPAKey := helper.GetCronFederatedHPAKey(cronFHPA) + handler.cronExecutorMap = make(map[string]map[string]RuleCron) + handler.cronExecutorMap[cronFHPAKey] = make(map[string]RuleCron) + + err := handler.CreateCronJobForExecutor(cronFHPA, rule) + assert.NoError(t, err) + + nextTime, err := handler.GetRuleNextExecuteTime(cronFHPA, rule.Name) + assert.NoError(t, err) + assert.False(t, nextTime.IsZero()) + assert.True(t, nextTime.After(time.Now())) + + _, err = handler.GetRuleNextExecuteTime(cronFHPA, "non-existent-rule") + assert.Error(t, err) + + handler.cronExecutorMap[cronFHPAKey][rule.Name].Scheduler.Stop() +} diff --git a/pkg/controllers/cronfederatedhpa/cronfederatedhpa_job_test.go b/pkg/controllers/cronfederatedhpa/cronfederatedhpa_job_test.go new file mode 100644 index 000000000000..346e03ae6427 --- /dev/null +++ b/pkg/controllers/cronfederatedhpa/cronfederatedhpa_job_test.go @@ -0,0 +1,241 @@ +/* +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 cronfederatedhpa + +import ( + "context" + "testing" + "time" + + "github.com/go-co-op/gocron" + "github.com/stretchr/testify/assert" + autoscalingv2 "k8s.io/api/autoscaling/v2" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + autoscalingv1alpha1 "github.com/karmada-io/karmada/pkg/apis/autoscaling/v1alpha1" +) + +func TestNewCronFederatedHPAJob(t *testing.T) { + client := fake.NewClientBuilder().Build() + eventRecorder := record.NewFakeRecorder(100) + scheduler := gocron.NewScheduler(time.UTC) + cronFHPA := &autoscalingv1alpha1.CronFederatedHPA{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cron-fhpa", + Namespace: "default", + }, + } + rule := autoscalingv1alpha1.CronFederatedHPARule{ + Name: "test-rule", + } + + job := NewCronFederatedHPAJob(client, eventRecorder, scheduler, cronFHPA, rule) + + assert.NotNil(t, job) + assert.Equal(t, client, job.client) + assert.Equal(t, eventRecorder, job.eventRecorder) + assert.Equal(t, scheduler, job.scheduler) + assert.Equal(t, cronFHPA.Name, job.namespaceName.Name) + assert.Equal(t, cronFHPA.Namespace, job.namespaceName.Namespace) + assert.Equal(t, rule, job.rule) +} + +func TestScaleFHPA(t *testing.T) { + tests := []struct { + name string + cronFHPA *autoscalingv1alpha1.CronFederatedHPA + existingFHPA *autoscalingv1alpha1.FederatedHPA + rule autoscalingv1alpha1.CronFederatedHPARule + expectedUpdate bool + expectedErr bool + }{ + { + name: "Update MaxReplicas", + cronFHPA: &autoscalingv1alpha1.CronFederatedHPA{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cron-fhpa", + Namespace: "default", + }, + Spec: autoscalingv1alpha1.CronFederatedHPASpec{ + ScaleTargetRef: autoscalingv2.CrossVersionObjectReference{ + Name: "test-fhpa", + }, + }, + }, + existingFHPA: &autoscalingv1alpha1.FederatedHPA{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-fhpa", + Namespace: "default", + }, + Spec: autoscalingv1alpha1.FederatedHPASpec{ + MaxReplicas: 5, + }, + }, + rule: autoscalingv1alpha1.CronFederatedHPARule{ + TargetMaxReplicas: intPtr(10), + }, + expectedUpdate: true, + expectedErr: false, + }, + { + name: "Update MinReplicas", + cronFHPA: &autoscalingv1alpha1.CronFederatedHPA{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cron-fhpa", + Namespace: "default", + }, + Spec: autoscalingv1alpha1.CronFederatedHPASpec{ + ScaleTargetRef: autoscalingv2.CrossVersionObjectReference{ + Name: "test-fhpa", + }, + }, + }, + existingFHPA: &autoscalingv1alpha1.FederatedHPA{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-fhpa", + Namespace: "default", + }, + Spec: autoscalingv1alpha1.FederatedHPASpec{ + MinReplicas: intPtr(2), + }, + }, + rule: autoscalingv1alpha1.CronFederatedHPARule{ + TargetMinReplicas: intPtr(3), + }, + expectedUpdate: true, + expectedErr: false, + }, + { + name: "No Updates Needed", + cronFHPA: &autoscalingv1alpha1.CronFederatedHPA{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cron-fhpa", + Namespace: "default", + }, + Spec: autoscalingv1alpha1.CronFederatedHPASpec{ + ScaleTargetRef: autoscalingv2.CrossVersionObjectReference{ + Name: "test-fhpa", + }, + }, + }, + existingFHPA: &autoscalingv1alpha1.FederatedHPA{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-fhpa", + Namespace: "default", + }, + Spec: autoscalingv1alpha1.FederatedHPASpec{ + MinReplicas: intPtr(2), + MaxReplicas: 5, + }, + }, + rule: autoscalingv1alpha1.CronFederatedHPARule{ + TargetMinReplicas: intPtr(2), + TargetMaxReplicas: intPtr(5), + }, + expectedUpdate: false, + expectedErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := runtime.NewScheme() + _ = autoscalingv1alpha1.Install(scheme) + client := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(tt.existingFHPA).Build() + + job := &ScalingJob{ + client: client, + rule: tt.rule, + } + + err := job.ScaleFHPA(tt.cronFHPA) + + if tt.expectedErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + if tt.expectedUpdate { + updatedFHPA := &autoscalingv1alpha1.FederatedHPA{} + err := client.Get(context.TODO(), types.NamespacedName{Name: tt.existingFHPA.Name, Namespace: tt.existingFHPA.Namespace}, updatedFHPA) + assert.NoError(t, err) + if tt.rule.TargetMaxReplicas != nil { + assert.Equal(t, *tt.rule.TargetMaxReplicas, updatedFHPA.Spec.MaxReplicas) + } + if tt.rule.TargetMinReplicas != nil { + assert.Equal(t, *tt.rule.TargetMinReplicas, *updatedFHPA.Spec.MinReplicas) + } + } + }) + } +} + +func intPtr(i int32) *int32 { + return &i +} + +func TestFindExecutionHistory(t *testing.T) { + tests := []struct { + name string + histories []autoscalingv1alpha1.ExecutionHistory + ruleName string + expectedIndex int + }{ + { + name: "Found", + histories: []autoscalingv1alpha1.ExecutionHistory{ + {RuleName: "rule1"}, + {RuleName: "rule2"}, + {RuleName: "rule3"}, + }, + ruleName: "rule2", + expectedIndex: 1, + }, + { + name: "Not Found", + histories: []autoscalingv1alpha1.ExecutionHistory{ + {RuleName: "rule1"}, + {RuleName: "rule2"}, + }, + ruleName: "rule3", + expectedIndex: -1, + }, + { + name: "Empty History", + histories: []autoscalingv1alpha1.ExecutionHistory{}, + ruleName: "rule1", + expectedIndex: -1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + job := &ScalingJob{ + rule: autoscalingv1alpha1.CronFederatedHPARule{ + Name: tt.ruleName, + }, + } + result := job.findExecutionHistory(tt.histories) + assert.Equal(t, tt.expectedIndex, result) + }) + } +}