diff --git a/pkg/controllers/v1alpha1/fluidapp/dataflowaffinity/dataflowaffinity_controller_test.go b/pkg/controllers/v1alpha1/fluidapp/dataflowaffinity/dataflowaffinity_controller_test.go index a132fd3947c..d86e36518ae 100644 --- a/pkg/controllers/v1alpha1/fluidapp/dataflowaffinity/dataflowaffinity_controller_test.go +++ b/pkg/controllers/v1alpha1/fluidapp/dataflowaffinity/dataflowaffinity_controller_test.go @@ -1,5 +1,5 @@ /* - Copyright 2024 The Fluid Authors. + Copyright 2026 The Fluid Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -21,41 +21,53 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - - datav1alpha1 "github.com/fluid-cloudnative/fluid/api/v1alpha1" - "github.com/fluid-cloudnative/fluid/pkg/common" - "github.com/fluid-cloudnative/fluid/pkg/utils/fake" batchv1 "k8s.io/api/batch/v1" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/reconcile" + + datav1alpha1 "github.com/fluid-cloudnative/fluid/api/v1alpha1" + "github.com/fluid-cloudnative/fluid/pkg/common" + "github.com/fluid-cloudnative/fluid/pkg/utils/fake" ) +func newTestScheme() *runtime.Scheme { + testScheme := runtime.NewScheme() + _ = v1.AddToScheme(testScheme) + _ = batchv1.AddToScheme(testScheme) + _ = datav1alpha1.AddToScheme(testScheme) + return testScheme +} + var _ = Describe("DataOpJobReconciler", func() { - const controllerUIDKey = "controller-uid" + const ( + controllerUIDKey = "controller-uid" + testNodeName = "node01" + testPodName = "test-pod" + testJobName = "test-job" + customLabelKey = "custom-label" + testControllerUID = "test-uid" + ) var testScheme *runtime.Scheme BeforeEach(func() { - testScheme = runtime.NewScheme() - Expect(v1.AddToScheme(testScheme)).To(Succeed()) - Expect(batchv1.AddToScheme(testScheme)).To(Succeed()) - Expect(datav1alpha1.AddToScheme(testScheme)).To(Succeed()) + testScheme = newTestScheme() }) Describe("ControllerName", func() { It("returns the controller name constant", func() { - f := &DataOpJobReconciler{Log: fake.NullLogger()} - Expect(f.ControllerName()).To(Equal(DataOpJobControllerName)) + reconciler := &DataOpJobReconciler{Log: fake.NullLogger()} + Expect(reconciler.ControllerName()).To(Equal(DataOpJobControllerName)) }) }) Describe("ManagedResource", func() { It("returns a batchv1.Job object", func() { - f := &DataOpJobReconciler{Log: fake.NullLogger()} - obj := f.ManagedResource() + reconciler := &DataOpJobReconciler{Log: fake.NullLogger()} + obj := reconciler.ManagedResource() Expect(obj).To(BeAssignableToTypeOf(&batchv1.Job{})) }) }) @@ -72,18 +84,18 @@ var _ = Describe("DataOpJobReconciler", func() { Describe("Reconcile", func() { Context("when the job does not exist", func() { - It("returns an error (not-found propagates)", func() { + It("returns an error when the job is missing", func() { c := fake.NewFakeClientWithScheme(testScheme) - f := &DataOpJobReconciler{Client: c, Log: fake.NullLogger()} - _, err := f.Reconcile(context.Background(), reconcile.Request{ + reconciler := &DataOpJobReconciler{Client: c, Log: fake.NullLogger()} + _, err := reconciler.Reconcile(context.Background(), reconcile.Request{ NamespacedName: types.NamespacedName{Name: "missing-job", Namespace: "default"}, }) Expect(err).To(HaveOccurred()) }) }) - Context("when job should not be in queue (cronjob label)", func() { - It("returns no-requeue without error", func() { + Context("when job should not be in queue", func() { + It("returns no requeue without error for cron jobs", func() { job := &batchv1.Job{ ObjectMeta: metav1.ObjectMeta{ Name: "cron-job", @@ -95,8 +107,8 @@ var _ = Describe("DataOpJobReconciler", func() { }, } c := fake.NewFakeClientWithScheme(testScheme, job) - f := &DataOpJobReconciler{Client: c, Log: fake.NullLogger()} - result, err := f.Reconcile(context.Background(), reconcile.Request{ + reconciler := &DataOpJobReconciler{Client: c, Log: fake.NullLogger()} + result, err := reconciler.Reconcile(context.Background(), reconcile.Request{ NamespacedName: types.NamespacedName{Name: "cron-job", Namespace: "default"}, }) Expect(err).NotTo(HaveOccurred()) @@ -105,9 +117,7 @@ var _ = Describe("DataOpJobReconciler", func() { }) Context("when job is a valid fluid job without affinity annotation", func() { - It("injects the dataflow affinity annotation and returns no-requeue", func() { - const testJobName = "test-job" - + It("injects the dataflow affinity annotation and returns no requeue", func() { job := &batchv1.Job{ ObjectMeta: metav1.ObjectMeta{ Name: testJobName, @@ -118,8 +128,8 @@ var _ = Describe("DataOpJobReconciler", func() { }, } c := fake.NewFakeClientWithScheme(testScheme, job) - f := &DataOpJobReconciler{Client: c, Log: fake.NullLogger()} - result, err := f.Reconcile(context.Background(), reconcile.Request{ + reconciler := &DataOpJobReconciler{Client: c, Log: fake.NullLogger()} + result, err := reconciler.Reconcile(context.Background(), reconcile.Request{ NamespacedName: types.NamespacedName{Name: testJobName, Namespace: "default"}, }) Expect(err).NotTo(HaveOccurred()) @@ -132,10 +142,8 @@ var _ = Describe("DataOpJobReconciler", func() { }) Context("when job is complete and has a succeeded pod", func() { - It("injects node labels and returns no-requeue", func() { - const ( - completeJobName = "complete-job" - ) + It("injects node labels and returns no requeue", func() { + const completeJobName = "complete-job" job := &batchv1.Job{ ObjectMeta: metav1.ObjectMeta{ @@ -156,9 +164,7 @@ var _ = Describe("DataOpJobReconciler", func() { }, }, Status: batchv1.JobStatus{ - Conditions: []batchv1.JobCondition{ - {Type: batchv1.JobComplete}, - }, + Conditions: []batchv1.JobCondition{{Type: batchv1.JobComplete}}, }, } pod := &v1.Pod{ @@ -169,26 +175,22 @@ var _ = Describe("DataOpJobReconciler", func() { controllerUIDKey: "abc-123", }, }, - Spec: v1.PodSpec{ - NodeName: "node01", - }, - Status: v1.PodStatus{ - Phase: v1.PodSucceeded, - }, + Spec: v1.PodSpec{NodeName: testNodeName}, + Status: v1.PodStatus{Phase: v1.PodSucceeded}, } node := &v1.Node{ ObjectMeta: metav1.ObjectMeta{ - Name: "node01", + Name: testNodeName, Labels: map[string]string{ - common.K8sNodeNameLabelKey: "node01", + common.K8sNodeNameLabelKey: testNodeName, common.K8sRegionLabelKey: "region01", common.K8sZoneLabelKey: "zone01", }, }, } c := fake.NewFakeClientWithScheme(testScheme, job, pod, node) - f := &DataOpJobReconciler{Client: c, Log: fake.NullLogger()} - result, err := f.Reconcile(context.Background(), reconcile.Request{ + reconciler := &DataOpJobReconciler{Client: c, Log: fake.NullLogger()} + result, err := reconciler.Reconcile(context.Background(), reconcile.Request{ NamespacedName: types.NamespacedName{Name: completeJobName, Namespace: "default"}, }) Expect(err).NotTo(HaveOccurred()) @@ -196,21 +198,76 @@ var _ = Describe("DataOpJobReconciler", func() { updatedJob := &batchv1.Job{} Expect(c.Get(context.Background(), types.NamespacedName{Name: completeJobName, Namespace: "default"}, updatedJob)).To(Succeed()) - Expect(updatedJob.Annotations).To(HaveKeyWithValue(common.AnnotationDataFlowCustomizedAffinityPrefix+common.K8sNodeNameLabelKey, "node01")) + Expect(updatedJob.Annotations).To(HaveKeyWithValue(common.AnnotationDataFlowCustomizedAffinityPrefix+common.K8sNodeNameLabelKey, testNodeName)) Expect(updatedJob.Annotations).To(HaveKeyWithValue(common.AnnotationDataFlowCustomizedAffinityPrefix+common.K8sRegionLabelKey, "region01")) Expect(updatedJob.Annotations).To(HaveKeyWithValue(common.AnnotationDataFlowCustomizedAffinityPrefix+common.K8sZoneLabelKey, "zone01")) }) }) }) + Describe("fillCustomizedNodeAffinity", func() { + It("fills annotations with node labels", func() { + annotationsToInject := map[string]string{} + nodeLabels := map[string]string{ + common.K8sNodeNameLabelKey: testNodeName, + common.K8sRegionLabelKey: "region01", + common.K8sZoneLabelKey: "zone01", + customLabelKey: "custom-value", + } + exposedLabelNames := []string{ + common.K8sNodeNameLabelKey, + common.K8sRegionLabelKey, + common.K8sZoneLabelKey, + customLabelKey, + } + + fillCustomizedNodeAffinity(annotationsToInject, nodeLabels, exposedLabelNames) + + Expect(annotationsToInject).To(HaveLen(4)) + Expect(annotationsToInject[common.AnnotationDataFlowCustomizedAffinityPrefix+common.K8sNodeNameLabelKey]).To(Equal(testNodeName)) + Expect(annotationsToInject[common.AnnotationDataFlowCustomizedAffinityPrefix+common.K8sRegionLabelKey]).To(Equal("region01")) + Expect(annotationsToInject[common.AnnotationDataFlowCustomizedAffinityPrefix+common.K8sZoneLabelKey]).To(Equal("zone01")) + Expect(annotationsToInject[common.AnnotationDataFlowCustomizedAffinityPrefix+customLabelKey]).To(Equal("custom-value")) + }) + + It("skips non-existent labels", func() { + annotationsToInject := map[string]string{} + nodeLabels := map[string]string{ + common.K8sNodeNameLabelKey: testNodeName, + } + exposedLabelNames := []string{ + common.K8sNodeNameLabelKey, + "non-existent-label", + } + + fillCustomizedNodeAffinity(annotationsToInject, nodeLabels, exposedLabelNames) + + Expect(annotationsToInject).To(HaveLen(1)) + Expect(annotationsToInject[common.AnnotationDataFlowCustomizedAffinityPrefix+common.K8sNodeNameLabelKey]).To(Equal(testNodeName)) + }) + + It("handles labels with whitespace", func() { + annotationsToInject := map[string]string{} + nodeLabels := map[string]string{ + common.K8sNodeNameLabelKey: testNodeName, + } + exposedLabelNames := []string{" " + common.K8sNodeNameLabelKey + " "} + + fillCustomizedNodeAffinity(annotationsToInject, nodeLabels, exposedLabelNames) + + Expect(annotationsToInject).To(HaveLen(1)) + Expect(annotationsToInject[common.AnnotationDataFlowCustomizedAffinityPrefix+common.K8sNodeNameLabelKey]).To(Equal(testNodeName)) + }) + }) + Describe("injectPodNodeLabelsToJob", func() { const jobControllerUIDValue = "455afc34-93b1-4e75-a6fa-8e13d2c6ca06" Context("when job has a succeeded pod", func() { - It("should inject node labels as annotations onto the job", func() { + It("injects node labels as annotations onto the job", func() { job := &batchv1.Job{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-job", + Name: testJobName, Labels: map[string]string{ common.LabelAnnotationManagedBy: common.Fluid, }, @@ -225,7 +282,7 @@ var _ = Describe("DataOpJobReconciler", func() { } pod := &v1.Pod{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-pod", + Name: testPodName, Labels: map[string]string{ controllerUIDKey: jobControllerUIDValue, }, @@ -234,34 +291,28 @@ var _ = Describe("DataOpJobReconciler", func() { }, }, Spec: v1.PodSpec{ - NodeName: "node01", + NodeName: testNodeName, Affinity: &v1.Affinity{ NodeAffinity: &v1.NodeAffinity{ RequiredDuringSchedulingIgnoredDuringExecution: &v1.NodeSelector{ - NodeSelectorTerms: []v1.NodeSelectorTerm{ - { - MatchExpressions: []v1.NodeSelectorRequirement{ - { - Key: "k8s.gpu", - Operator: v1.NodeSelectorOpIn, - Values: []string{"true"}, - }, - }, - }, - }, + NodeSelectorTerms: []v1.NodeSelectorTerm{{ + MatchExpressions: []v1.NodeSelectorRequirement{{ + Key: "k8s.gpu", + Operator: v1.NodeSelectorOpIn, + Values: []string{"true"}, + }}, + }}, }, }, }, }, - Status: v1.PodStatus{ - Phase: v1.PodSucceeded, - }, + Status: v1.PodStatus{Phase: v1.PodSucceeded}, } node := &v1.Node{ ObjectMeta: metav1.ObjectMeta{ - Name: "node01", + Name: testNodeName, Labels: map[string]string{ - common.K8sNodeNameLabelKey: "node01", + common.K8sNodeNameLabelKey: testNodeName, common.K8sRegionLabelKey: "region01", common.K8sZoneLabelKey: "zone01", "k8s.gpu": "true", @@ -270,30 +321,24 @@ var _ = Describe("DataOpJobReconciler", func() { } c := fake.NewFakeClientWithScheme(testScheme, job, pod, node) - f := &DataOpJobReconciler{ - Client: c, - Log: fake.NullLogger(), - } + reconciler := &DataOpJobReconciler{Client: c, Log: fake.NullLogger()} - err := f.injectPodNodeLabelsToJob(job) + err := reconciler.injectPodNodeLabelsToJob(job) Expect(err).NotTo(HaveOccurred()) - wantAnnotations := map[string]string{ - common.AnnotationDataFlowCustomizedAffinityPrefix + common.K8sNodeNameLabelKey: "node01", + expectedAnnotations := map[string]string{ + common.AnnotationDataFlowCustomizedAffinityPrefix + common.K8sNodeNameLabelKey: testNodeName, common.AnnotationDataFlowCustomizedAffinityPrefix + common.K8sRegionLabelKey: "region01", common.AnnotationDataFlowCustomizedAffinityPrefix + common.K8sZoneLabelKey: "zone01", common.AnnotationDataFlowCustomizedAffinityPrefix + "k8s.gpu": "true", } - Expect(job.Annotations).To(Equal(wantAnnotations)) + Expect(job.Annotations).To(Equal(expectedAnnotations)) }) }) Context("when job has only a failed pod", func() { - It("should return an error", func() { + It("returns an error", func() { job := &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-job-failed", - }, Spec: batchv1.JobSpec{ Selector: &metav1.LabelSelector{ MatchLabels: map[string]string{ @@ -304,32 +349,110 @@ var _ = Describe("DataOpJobReconciler", func() { } pod := &v1.Pod{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-pod", + Name: testPodName, Labels: map[string]string{ controllerUIDKey: jobControllerUIDValue, }, }, - Status: v1.PodStatus{ - Phase: v1.PodFailed, - }, + Status: v1.PodStatus{Phase: v1.PodFailed}, } node := &v1.Node{ ObjectMeta: metav1.ObjectMeta{ - Name: "node01", + Name: testNodeName, Labels: map[string]string{ - common.K8sNodeNameLabelKey: "node01", + common.K8sNodeNameLabelKey: testNodeName, }, }, } c := fake.NewFakeClientWithScheme(testScheme, job, pod, node) - f := &DataOpJobReconciler{ - Client: c, - Log: fake.NullLogger(), + reconciler := &DataOpJobReconciler{Client: c, Log: fake.NullLogger()} + + err := reconciler.injectPodNodeLabelsToJob(job) + Expect(err).To(HaveOccurred()) + }) + }) + + Context("when pod has no node name", func() { + It("returns an error", func() { + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: testJobName, + Labels: map[string]string{ + common.LabelAnnotationManagedBy: common.Fluid, + }, + }, + Spec: batchv1.JobSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + controllerUIDKey: testControllerUID, + }, + }, + }, + } + pod := &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: testPodName, + Labels: map[string]string{ + controllerUIDKey: testControllerUID, + }, + }, + Spec: v1.PodSpec{NodeName: ""}, + Status: v1.PodStatus{Phase: v1.PodSucceeded}, } - err := f.injectPodNodeLabelsToJob(job) + c := fake.NewFakeClientWithScheme(testScheme, job, pod) + reconciler := &DataOpJobReconciler{Client: c, Log: fake.NullLogger()} + + err := reconciler.injectPodNodeLabelsToJob(job) Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no node name")) + }) + }) + + Context("when job has nil annotations", func() { + It("creates the annotations map before injecting labels", func() { + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: testJobName, + Labels: map[string]string{ + common.LabelAnnotationManagedBy: common.Fluid, + }, + Annotations: nil, + }, + Spec: batchv1.JobSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + controllerUIDKey: testControllerUID, + }, + }, + }, + } + pod := &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: testPodName, + Labels: map[string]string{ + controllerUIDKey: testControllerUID, + }, + }, + Spec: v1.PodSpec{NodeName: testNodeName}, + Status: v1.PodStatus{Phase: v1.PodSucceeded}, + } + node := &v1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: testNodeName, + Labels: map[string]string{ + common.K8sNodeNameLabelKey: testNodeName, + }, + }, + } + + c := fake.NewFakeClientWithScheme(testScheme, job, pod, node) + reconciler := &DataOpJobReconciler{Client: c, Log: fake.NullLogger()} + + err := reconciler.injectPodNodeLabelsToJob(job) + Expect(err).NotTo(HaveOccurred()) + Expect(job.Annotations).NotTo(BeNil()) }) }) }) diff --git a/pkg/controllers/v1alpha1/fluidapp/dataflowaffinity/suite_test.go b/pkg/controllers/v1alpha1/fluidapp/dataflowaffinity/suite_test.go index 30c0e4bbfe4..64aeb4e0b9a 100644 --- a/pkg/controllers/v1alpha1/fluidapp/dataflowaffinity/suite_test.go +++ b/pkg/controllers/v1alpha1/fluidapp/dataflowaffinity/suite_test.go @@ -1,5 +1,5 @@ /* - Copyright 2024 The Fluid Authors. + Copyright 2026 The Fluid Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/pkg/controllers/v1alpha1/fluidapp/fluidapp_controller_test.go b/pkg/controllers/v1alpha1/fluidapp/fluidapp_controller_test.go new file mode 100644 index 00000000000..504dc9f651c --- /dev/null +++ b/pkg/controllers/v1alpha1/fluidapp/fluidapp_controller_test.go @@ -0,0 +1,205 @@ +/* + Copyright 2026 The Fluid 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 fluidapp + +import ( + "context" + + "github.com/agiledragon/gomonkey/v2" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/fluid-cloudnative/fluid/pkg/common" + "github.com/fluid-cloudnative/fluid/pkg/utils/fake" + "github.com/fluid-cloudnative/fluid/pkg/utils/kubeclient" +) + +func newFluidAppScheme() *runtime.Scheme { + s := runtime.NewScheme() + _ = corev1.AddToScheme(s) + return s +} + +var _ = Describe("FluidAppReconciler", func() { + const ( + defaultNamespace = "default" + testPodName = "test-pod" + ) + + Describe("ControllerName", func() { + It("should return FluidAppController", func() { + reconciler := &FluidAppReconciler{} + Expect(reconciler.ControllerName()).To(Equal("FluidAppController")) + }) + }) + + Describe("ManagedResource", func() { + It("should return a Pod object", func() { + reconciler := &FluidAppReconciler{} + obj := reconciler.ManagedResource() + Expect(obj).NotTo(BeNil()) + _, ok := obj.(*corev1.Pod) + Expect(ok).To(BeTrue()) + }) + }) + + Describe("NewFluidAppReconciler", func() { + It("should create a new reconciler", func() { + s := newFluidAppScheme() + c := fake.NewFakeClientWithScheme(s) + reconciler := NewFluidAppReconciler(c, fake.NullLogger(), record.NewFakeRecorder(100)) + Expect(reconciler).NotTo(BeNil()) + Expect(reconciler.Client).NotTo(BeNil()) + Expect(reconciler.FluidAppReconcilerImplement).NotTo(BeNil()) + }) + }) + + Describe("Reconcile", func() { + Context("when pod does not exist", func() { + It("should return no error and not requeue", func() { + s := newFluidAppScheme() + c := fake.NewFakeClientWithScheme(s) + reconciler := NewFluidAppReconciler(c, fake.NullLogger(), record.NewFakeRecorder(100)) + + req := reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: "non-existent-pod", + Namespace: defaultNamespace, + }, + } + + result, err := reconciler.Reconcile(context.Background(), req) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Requeue).To(BeFalse()) + }) + }) + + Context("when pod should not be in queue", func() { + It("should return no error and not requeue", func() { + s := newFluidAppScheme() + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: testPodName, + Namespace: defaultNamespace, + }, + } + c := fake.NewFakeClientWithScheme(s, pod) + reconciler := NewFluidAppReconciler(c, fake.NullLogger(), record.NewFakeRecorder(100)) + + req := reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: testPodName, + Namespace: defaultNamespace, + }, + } + + result, err := reconciler.Reconcile(context.Background(), req) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Requeue).To(BeFalse()) + }) + }) + }) + + Describe("internalReconcile", func() { + Context("when pod has fuse containers", func() { + It("should handle pod reconciliation with fuse sidecars", func() { + patches := gomonkey.ApplyFunc(kubeclient.ExecCommandInContainer, func(string, string, string, []string) (string, string, error) { + return "", "", nil + }) + defer patches.Reset() + + s := newFluidAppScheme() + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: testPodName, + Namespace: defaultNamespace, + Labels: map[string]string{ + common.LabelAnnotationManagedBy: common.Fluid, + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "test"}, + { + Name: common.FuseContainerName + "-0", + VolumeMounts: []corev1.VolumeMount{{ + Name: "juicefs-fuse-mount", + MountPath: "/mnt/fuse", + }}, + }, + }, + }, + } + c := fake.NewFakeClientWithScheme(s, pod) + reconciler := NewFluidAppReconciler(c, fake.NullLogger(), record.NewFakeRecorder(100)) + + ctx := reconcileRequestContext{ + Context: context.Background(), + Log: fake.NullLogger(), + pod: pod, + NamespacedName: types.NamespacedName{ + Name: testPodName, + Namespace: defaultNamespace, + }, + } + + result, err := reconciler.internalReconcile(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{})) + }) + }) + + It("should handle pod reconciliation", func() { + s := newFluidAppScheme() + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: testPodName, + Namespace: defaultNamespace, + Labels: map[string]string{ + common.LabelAnnotationManagedBy: common.Fluid, + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "test"}}, + }, + } + c := fake.NewFakeClientWithScheme(s, pod) + reconciler := NewFluidAppReconciler(c, fake.NullLogger(), record.NewFakeRecorder(100)) + + ctx := reconcileRequestContext{ + Context: context.Background(), + Log: fake.NullLogger(), + pod: pod, + NamespacedName: types.NamespacedName{ + Name: testPodName, + Namespace: defaultNamespace, + }, + } + + result, err := reconciler.internalReconcile(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{})) + }) + }) +}) diff --git a/pkg/controllers/v1alpha1/fluidapp/implement_test.go b/pkg/controllers/v1alpha1/fluidapp/implement_test.go index a1434c4c392..71235775c49 100644 --- a/pkg/controllers/v1alpha1/fluidapp/implement_test.go +++ b/pkg/controllers/v1alpha1/fluidapp/implement_test.go @@ -1,5 +1,5 @@ /* - Copyright 2022 The Fluid Authors. + Copyright 2026 The Fluid Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,70 +17,86 @@ package fluidapp import ( - "testing" + "fmt" "github.com/agiledragon/gomonkey/v2" - "github.com/go-logr/logr" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/tools/record" - "sigs.k8s.io/controller-runtime/pkg/client" "github.com/fluid-cloudnative/fluid/pkg/common" "github.com/fluid-cloudnative/fluid/pkg/utils/fake" "github.com/fluid-cloudnative/fluid/pkg/utils/kubeclient" ) -func TestFluidAppReconcilerImplement_umountFuseSidecars(t *testing.T) { - mockExec := func(p1, p2, p3 string, p4 []string) (stdout string, stderr string, e error) { - return "", "", nil - } - - patches := gomonkey.ApplyFunc(kubeclient.ExecCommandInContainer, mockExec) - defer patches.Reset() - - type fields struct { - Client client.Client - Log logr.Logger - Recorder record.EventRecorder - } - type args struct { - pod *corev1.Pod - } - tests := []struct { - name string - fields fields - args args - wantErr bool - }{ - { - name: "test-no-fuse", - args: args{ - pod: &corev1.Pod{ +var _ = Describe("FluidAppReconcilerImplement", func() { + const ( + fuseMountPath = "/mnt/fuse" + juicefsFuseMount = "juicefs-fuse-mount" + juicefsMountPath = "/mnt/jfs" + shouldSucceedWithoutErrors = "should succeed without errors" + ) + + Describe("NewFluidAppReconcilerImplement", func() { + It("should create a new reconciler", func() { + reconciler := NewFluidAppReconcilerImplement(nil, fake.NullLogger(), nil) + Expect(reconciler).NotTo(BeNil()) + Expect(reconciler.Log).NotTo(BeNil()) + }) + }) + + Describe("umountFuseSidecars", func() { + var patches *gomonkey.Patches + var reconciler *FluidAppReconcilerImplement + + BeforeEach(func() { + mockExec := func(p1, p2, p3 string, p4 []string) (stdout string, stderr string, e error) { + return "", "", nil + } + patches = gomonkey.ApplyFunc(kubeclient.ExecCommandInContainer, mockExec) + reconciler = &FluidAppReconcilerImplement{ + Log: fake.NullLogger(), + } + }) + + AfterEach(func() { + if patches != nil { + patches.Reset() + } + }) + + Context("when pod has no fuse containers", func() { + It(shouldSucceedWithoutErrors, func() { + pod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{Name: "test"}, Spec: corev1.PodSpec{ Containers: []corev1.Container{{Name: "test"}}, }, - }, - }, - wantErr: false, - }, - { - name: "test-no-mountpath", - args: args{ - pod: &corev1.Pod{ + } + + err := reconciler.umountFuseSidecars(pod) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("when fuse container has no mount path", func() { + It(shouldSucceedWithoutErrors, func() { + pod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{Name: "test"}, Spec: corev1.PodSpec{ Containers: []corev1.Container{{Name: common.FuseContainerName + "-0"}}, }, - }, - }, - wantErr: false, - }, - { - name: "test-prestop", - args: args{ - pod: &corev1.Pod{ + } + + err := reconciler.umountFuseSidecars(pod) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("when fuse container has prestop hook", func() { + It(shouldSucceedWithoutErrors, func() { + pod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{Name: "test"}, Spec: corev1.PodSpec{ Containers: []corev1.Container{{ @@ -92,64 +108,143 @@ func TestFluidAppReconcilerImplement_umountFuseSidecars(t *testing.T) { }, }}, }, - }, - }, - wantErr: false, - }, - { - name: "test-mountpath", - args: args{ - pod: &corev1.Pod{ + } + + err := reconciler.umountFuseSidecars(pod) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("when fuse container has mount path", func() { + It("should succeed and umount the path", func() { + pod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{Name: "test"}, Spec: corev1.PodSpec{ Containers: []corev1.Container{{ Name: common.FuseContainerName + "-0", VolumeMounts: []corev1.VolumeMount{{ - Name: "juicefs-fuse-mount", - MountPath: "/mnt/jfs", + Name: juicefsFuseMount, + MountPath: juicefsMountPath, }}, }}, }, - }, - }, - wantErr: false, - }, - { - name: "test-multi-sidecar", - args: args{ - pod: &corev1.Pod{ + } + + err := reconciler.umountFuseSidecars(pod) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("when pod has multiple fuse sidecars", func() { + It("should succeed and umount all paths", func() { + pod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{Name: "test"}, Spec: corev1.PodSpec{ Containers: []corev1.Container{ { Name: common.FuseContainerName + "-0", VolumeMounts: []corev1.VolumeMount{{ - Name: "juicefs-fuse-mount", - MountPath: "/mnt/jfs", + Name: juicefsFuseMount, + MountPath: juicefsMountPath, }}, }, { Name: common.FuseContainerName + "-1", VolumeMounts: []corev1.VolumeMount{{ - Name: "juicefs-fuse-mount", - MountPath: "/mnt/jfs", + Name: juicefsFuseMount, + MountPath: juicefsMountPath, }}, }, }, }, - }, - }, - wantErr: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - i := &FluidAppReconcilerImplement{ + } + + err := reconciler.umountFuseSidecars(pod) + Expect(err).NotTo(HaveOccurred()) + }) + }) + }) + + Describe("umountFuseSidecar", func() { + var patches *gomonkey.Patches + var reconciler *FluidAppReconcilerImplement + + BeforeEach(func() { + reconciler = &FluidAppReconcilerImplement{ Log: fake.NullLogger(), } - if err := i.umountFuseSidecars(tt.args.pod); (err != nil) != tt.wantErr { - t.Errorf("umountFuseSidecar() error = %v, wantErr %v", err, tt.wantErr) + }) + + AfterEach(func() { + if patches != nil { + patches.Reset() } }) - } -} + + Context("when fuse container has empty name", func() { + It("should return nil", func() { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + } + container := corev1.Container{Name: ""} + + patches = gomonkey.ApplyFunc(kubeclient.ExecCommandInContainer, func(p1, p2, p3 string, p4 []string) (stdout string, stderr string, e error) { + return "", "", nil + }) + + err := reconciler.umountFuseSidecar(pod, container) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("when exec fails with 'not mounted' error", func() { + It("should return nil and not retry", func() { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + container := corev1.Container{ + Name: common.FuseContainerName + "-0", + VolumeMounts: []corev1.VolumeMount{{ + Name: "fuse-mount", + MountPath: fuseMountPath, + }}, + } + + patches = gomonkey.ApplyFunc(kubeclient.GetMountPathInContainer, func(c corev1.Container) (string, error) { + return fuseMountPath, nil + }) + patches.ApplyFunc(kubeclient.ExecCommandInContainer, func(p1, p2, p3 string, p4 []string) (stdout string, stderr string, e error) { + return "", "not mounted", fmt.Errorf("umount failed") + }) + + err := reconciler.umountFuseSidecar(pod, container) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("when exec fails with exit code 137", func() { + It("should return nil and not retry", func() { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + container := corev1.Container{ + Name: common.FuseContainerName + "-0", + VolumeMounts: []corev1.VolumeMount{{ + Name: "fuse-mount", + MountPath: fuseMountPath, + }}, + } + + patches = gomonkey.ApplyFunc(kubeclient.GetMountPathInContainer, func(c corev1.Container) (string, error) { + return fuseMountPath, nil + }) + patches.ApplyFunc(kubeclient.ExecCommandInContainer, func(p1, p2, p3 string, p4 []string) (stdout string, stderr string, e error) { + return "", "", fmt.Errorf("exit code 137") + }) + + err := reconciler.umountFuseSidecar(pod, container) + Expect(err).NotTo(HaveOccurred()) + }) + }) + }) +}) diff --git a/pkg/controllers/v1alpha1/fluidapp/suite_test.go b/pkg/controllers/v1alpha1/fluidapp/suite_test.go new file mode 100644 index 00000000000..ac414e387d7 --- /dev/null +++ b/pkg/controllers/v1alpha1/fluidapp/suite_test.go @@ -0,0 +1,29 @@ +/* + Copyright 2026 The Fluid 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 fluidapp + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestFluidapp(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Fluidapp Suite") +}