diff --git a/operator/internal/controller/podclique/reconcilespec_test.go b/operator/internal/controller/podclique/reconcilespec_test.go index c0d0f1337..43507e57a 100644 --- a/operator/internal/controller/podclique/reconcilespec_test.go +++ b/operator/internal/controller/podclique/reconcilespec_test.go @@ -17,13 +17,19 @@ package podclique import ( + "context" "testing" grovecorev1alpha1 "github.com/ai-dynamo/grove/operator/api/core/v1alpha1" "github.com/ai-dynamo/grove/operator/internal/controller/common/component" + testutils "github.com/ai-dynamo/grove/operator/test/utils" + "github.com/go-logr/logr" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/util/uuid" "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" ) // TestPcsHasNoActiveRollingUpdate tests the pcsHasNoActiveRollingUpdate function @@ -102,3 +108,84 @@ func TestGetOrderedKindsForSync(t *testing.T) { assert.Equal(t, 1, len(kinds)) assert.Equal(t, component.KindPod, kinds[0]) } + +// TestUpdateObservedGeneration tests the updateObservedGeneration function for PodClique +func TestUpdateObservedGeneration(t *testing.T) { + const ( + testNamespace = "test-namespace" + testPCSName = "test-pcs" + ) + + tests := []struct { + name string + setupPCLQ func() *grovecorev1alpha1.PodClique + expectPatchSkipped bool + expectedGeneration int64 + }{ + { + name: "patch_skipped_when_observed_equals_generation", + setupPCLQ: func() *grovecorev1alpha1.PodClique { + pclq := testutils.NewPodCliqueBuilder(testPCSName, uuid.NewUUID(), "worker", testNamespace, 0).Build() + pclq.Generation = 5 + pclq.Status.ObservedGeneration = ptr.To(int64(5)) + return pclq + }, + expectPatchSkipped: true, + expectedGeneration: 5, + }, + { + name: "patch_made_when_observed_is_nil", + setupPCLQ: func() *grovecorev1alpha1.PodClique { + pclq := testutils.NewPodCliqueBuilder(testPCSName, uuid.NewUUID(), "worker", testNamespace, 0).Build() + pclq.Generation = 3 + pclq.Status.ObservedGeneration = nil + return pclq + }, + expectPatchSkipped: false, + expectedGeneration: 3, + }, + { + name: "patch_made_when_observed_differs_from_generation", + setupPCLQ: func() *grovecorev1alpha1.PodClique { + pclq := testutils.NewPodCliqueBuilder(testPCSName, uuid.NewUUID(), "worker", testNamespace, 0).Build() + pclq.Generation = 7 + pclq.Status.ObservedGeneration = ptr.To(int64(4)) + return pclq + }, + expectPatchSkipped: false, + expectedGeneration: 7, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pclq := tt.setupPCLQ() + originalObservedGen := pclq.Status.ObservedGeneration + + fakeClient := testutils.SetupFakeClient(pclq) + reconciler := &Reconciler{client: fakeClient} + + result := reconciler.updateObservedGeneration(context.Background(), logr.Discard(), pclq) + + require.False(t, result.HasErrors(), "updateObservedGeneration should not return errors") + + // Verify the result continues reconciliation + _, err := result.Result() + assert.NoError(t, err) + + // Fetch the updated object from the fake client + updatedPCLQ := &grovecorev1alpha1.PodClique{} + err = fakeClient.Get(context.Background(), client.ObjectKeyFromObject(pclq), updatedPCLQ) + require.NoError(t, err) + + // Verify ObservedGeneration is set correctly + require.NotNil(t, updatedPCLQ.Status.ObservedGeneration, "ObservedGeneration should not be nil after update") + assert.Equal(t, tt.expectedGeneration, *updatedPCLQ.Status.ObservedGeneration) + + if tt.expectPatchSkipped { + // If patch was skipped, the ObservedGeneration should remain unchanged + assert.Equal(t, originalObservedGen, updatedPCLQ.Status.ObservedGeneration) + } + }) + } +} diff --git a/operator/internal/controller/podcliquescalinggroup/reconcilespec_test.go b/operator/internal/controller/podcliquescalinggroup/reconcilespec_test.go new file mode 100644 index 000000000..65485d79a --- /dev/null +++ b/operator/internal/controller/podcliquescalinggroup/reconcilespec_test.go @@ -0,0 +1,135 @@ +// /* +// Copyright 2025 The Grove 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 podcliquescalinggroup + +import ( + "context" + "testing" + + grovecorev1alpha1 "github.com/ai-dynamo/grove/operator/api/core/v1alpha1" + "github.com/ai-dynamo/grove/operator/internal/controller/common/component" + testutils "github.com/ai-dynamo/grove/operator/test/utils" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Test constants +const ( + testNamespacePCSG = "test-namespace" + testPCSGName = "test-pcsg" + testPCSNamePCSG = "test-pcs" +) + +// TestUpdateObservedGeneration tests the updateObservedGeneration function for PodCliqueScalingGroup +func TestUpdateObservedGeneration(t *testing.T) { + tests := []struct { + name string + setupPCSG func() *grovecorev1alpha1.PodCliqueScalingGroup + expectPatchSkipped bool + expectedGeneration int64 + }{ + { + name: "patch_skipped_when_observed_equals_generation", + setupPCSG: func() *grovecorev1alpha1.PodCliqueScalingGroup { + pcsg := testutils.NewPodCliqueScalingGroupBuilder(testPCSGName, testNamespacePCSG, testPCSNamePCSG, 0). + WithReplicas(1). + Build() + pcsg.Generation = 5 + pcsg.Status.ObservedGeneration = ptr.To(int64(5)) + return pcsg + }, + expectPatchSkipped: true, + expectedGeneration: 5, + }, + { + name: "patch_made_when_observed_is_nil", + setupPCSG: func() *grovecorev1alpha1.PodCliqueScalingGroup { + pcsg := testutils.NewPodCliqueScalingGroupBuilder(testPCSGName, testNamespacePCSG, testPCSNamePCSG, 0). + WithReplicas(1). + Build() + pcsg.Generation = 3 + pcsg.Status.ObservedGeneration = nil + return pcsg + }, + expectPatchSkipped: false, + expectedGeneration: 3, + }, + { + name: "patch_made_when_observed_differs_from_generation", + setupPCSG: func() *grovecorev1alpha1.PodCliqueScalingGroup { + pcsg := testutils.NewPodCliqueScalingGroupBuilder(testPCSGName, testNamespacePCSG, testPCSNamePCSG, 0). + WithReplicas(1). + Build() + pcsg.Generation = 7 + pcsg.Status.ObservedGeneration = ptr.To(int64(4)) + return pcsg + }, + expectPatchSkipped: false, + expectedGeneration: 7, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pcsg := tt.setupPCSG() + originalObservedGen := pcsg.Status.ObservedGeneration + + fakeClient := testutils.SetupFakeClient(pcsg) + reconciler := &Reconciler{client: fakeClient} + + result := reconciler.updateObservedGeneration(context.Background(), logr.Discard(), pcsg) + + require.False(t, result.HasErrors(), "updateObservedGeneration should not return errors") + + // Verify the result continues reconciliation + _, err := result.Result() + assert.NoError(t, err) + + // Fetch the updated object from the fake client + updatedPCSG := &grovecorev1alpha1.PodCliqueScalingGroup{} + err = fakeClient.Get(context.Background(), client.ObjectKeyFromObject(pcsg), updatedPCSG) + require.NoError(t, err) + + // Verify ObservedGeneration is set correctly + require.NotNil(t, updatedPCSG.Status.ObservedGeneration, "ObservedGeneration should not be nil after update") + assert.Equal(t, tt.expectedGeneration, *updatedPCSG.Status.ObservedGeneration) + + if tt.expectPatchSkipped { + // If patch was skipped, the ObservedGeneration should remain unchanged + assert.Equal(t, originalObservedGen, updatedPCSG.Status.ObservedGeneration) + } + }) + } +} + +// TestGetOrderedKindsForSyncPCSG tests the getOrderedKindsForSync function for PodCliqueScalingGroup +func TestGetOrderedKindsForSyncPCSG(t *testing.T) { + kinds := getOrderedKindsForSync() + + expectedKinds := []component.Kind{ + component.KindPodClique, + } + + assert.Equal(t, len(expectedKinds), len(kinds)) + for i, expected := range expectedKinds { + assert.Equal(t, expected, kinds[i]) + } +} diff --git a/operator/internal/controller/podcliqueset/reconcilespec_test.go b/operator/internal/controller/podcliqueset/reconcilespec_test.go new file mode 100644 index 000000000..007a2d706 --- /dev/null +++ b/operator/internal/controller/podcliqueset/reconcilespec_test.go @@ -0,0 +1,138 @@ +// /* +// Copyright 2025 The Grove 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 podcliqueset + +import ( + "context" + "testing" + + grovecorev1alpha1 "github.com/ai-dynamo/grove/operator/api/core/v1alpha1" + "github.com/ai-dynamo/grove/operator/internal/controller/common/component" + testutils "github.com/ai-dynamo/grove/operator/test/utils" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/util/uuid" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// TestUpdateObservedGeneration tests the updateObservedGeneration function for PodCliqueSet +func TestUpdateObservedGeneration(t *testing.T) { + tests := []struct { + name string + setupPCS func() *grovecorev1alpha1.PodCliqueSet + expectPatchSkipped bool + expectedGeneration int64 + }{ + { + name: "patch_skipped_when_observed_equals_generation", + setupPCS: func() *grovecorev1alpha1.PodCliqueSet { + pcs := testutils.NewPodCliqueSetBuilder(testPCSName, testNamespace, uuid.NewUUID()). + WithReplicas(1). + Build() + pcs.Generation = 5 + pcs.Status.ObservedGeneration = ptr.To(int64(5)) + return pcs + }, + expectPatchSkipped: true, + expectedGeneration: 5, + }, + { + name: "patch_made_when_observed_is_nil", + setupPCS: func() *grovecorev1alpha1.PodCliqueSet { + pcs := testutils.NewPodCliqueSetBuilder(testPCSName, testNamespace, uuid.NewUUID()). + WithReplicas(1). + Build() + pcs.Generation = 3 + pcs.Status.ObservedGeneration = nil + return pcs + }, + expectPatchSkipped: false, + expectedGeneration: 3, + }, + { + name: "patch_made_when_observed_differs_from_generation", + setupPCS: func() *grovecorev1alpha1.PodCliqueSet { + pcs := testutils.NewPodCliqueSetBuilder(testPCSName, testNamespace, uuid.NewUUID()). + WithReplicas(1). + Build() + pcs.Generation = 7 + pcs.Status.ObservedGeneration = ptr.To(int64(4)) + return pcs + }, + expectPatchSkipped: false, + expectedGeneration: 7, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pcs := tt.setupPCS() + originalObservedGen := pcs.Status.ObservedGeneration + + fakeClient := testutils.SetupFakeClient(pcs) + reconciler := &Reconciler{client: fakeClient} + + result := reconciler.updateObservedGeneration(context.Background(), logr.Discard(), pcs) + + require.False(t, result.HasErrors(), "updateObservedGeneration should not return errors") + + // Verify the result continues reconciliation + _, err := result.Result() + assert.NoError(t, err) + + // Fetch the updated object from the fake client + updatedPCS := &grovecorev1alpha1.PodCliqueSet{} + err = fakeClient.Get(context.Background(), client.ObjectKeyFromObject(pcs), updatedPCS) + require.NoError(t, err) + + // Verify ObservedGeneration is set correctly + require.NotNil(t, updatedPCS.Status.ObservedGeneration, "ObservedGeneration should not be nil after update") + assert.Equal(t, tt.expectedGeneration, *updatedPCS.Status.ObservedGeneration) + + if tt.expectPatchSkipped { + // If patch was skipped, the ObservedGeneration should remain unchanged + assert.Equal(t, originalObservedGen, updatedPCS.Status.ObservedGeneration) + } + }) + } +} + +// TestGetOrderedKindsForSync tests the getOrderedKindsForSync function +func TestGetOrderedKindsForSync(t *testing.T) { + kinds := getOrderedKindsForSync() + + expectedKinds := []component.Kind{ + component.KindServiceAccount, + component.KindRole, + component.KindRoleBinding, + component.KindServiceAccountTokenSecret, + component.KindHeadlessService, + component.KindHorizontalPodAutoscaler, + component.KindPodCliqueSetReplica, + component.KindPodClique, + component.KindPodCliqueScalingGroup, + component.KindPodGang, + } + + assert.Equal(t, len(expectedKinds), len(kinds)) + for i, expected := range expectedKinds { + assert.Equal(t, expected, kinds[i]) + } +}