diff --git a/pkg/controllers/v1alpha1/fluidapp/fluidapp_controller_ut_test.go b/pkg/controllers/v1alpha1/fluidapp/fluidapp_controller_ut_test.go new file mode 100644 index 00000000000..9d7dee6efc3 --- /dev/null +++ b/pkg/controllers/v1alpha1/fluidapp/fluidapp_controller_ut_test.go @@ -0,0 +1,237 @@ +/* +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" + "errors" + + "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/client" + "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" + "github.com/fluid-cloudnative/fluid/pkg/utils/kubeclient" +) + +var _ = Describe("FluidAppReconciler", func() { + const ( + testNamespace = "default" + testPodName = "fluidapp-pod" + ) + + var scheme *runtime.Scheme + var patches *gomonkey.Patches + + BeforeEach(func() { + scheme = runtime.NewScheme() + Expect(corev1.AddToScheme(scheme)).To(Succeed()) + Expect(datav1alpha1.AddToScheme(scheme)).To(Succeed()) + }) + + AfterEach(func() { + if patches != nil { + patches.Reset() + patches = nil + } + }) + + Describe("ControllerName", func() { + It("returns the controller name constant", func() { + r := &FluidAppReconciler{} + Expect(r.ControllerName()).To(Equal(controllerName)) + }) + }) + + Describe("ManagedResource", func() { + It("returns a pod object", func() { + r := &FluidAppReconciler{} + Expect(r.ManagedResource()).To(BeAssignableToTypeOf(&corev1.Pod{})) + }) + }) + + Describe("NewFluidAppReconciler", func() { + It("constructs a reconciler with the provided dependencies", func() { + fakeClient := fake.NewFakeClientWithScheme(scheme) + recorder := record.NewFakeRecorder(10) + + r := NewFluidAppReconciler(fakeClient, fake.NullLogger(), recorder) + + Expect(r).NotTo(BeNil()) + Expect(r.Client).To(Equal(fakeClient)) + Expect(r.Recorder).To(Equal(recorder)) + Expect(r.FluidAppReconcilerImplement).NotTo(BeNil()) + Expect(r.FluidAppReconcilerImplement.Client).To(Equal(fakeClient)) + }) + }) + + Describe("Reconcile", func() { + It("returns the pod lookup error", func() { + expectedErr := errors.New("get pod failed") + patches = gomonkey.ApplyFunc(kubeclient.GetPodByName, func(_ client.Client, name, namespace string) (*corev1.Pod, error) { + Expect(name).To(Equal(testPodName)) + Expect(namespace).To(Equal(testNamespace)) + return nil, expectedErr + }) + + r := NewFluidAppReconciler(fake.NewFakeClientWithScheme(scheme), fake.NullLogger(), record.NewFakeRecorder(10)) + + result, err := r.Reconcile(context.Background(), reconcile.Request{ + NamespacedName: types.NamespacedName{Name: testPodName, Namespace: testNamespace}, + }) + + Expect(err).To(MatchError(expectedErr)) + Expect(result).To(Equal(ctrl.Result{})) + }) + + It("returns no requeue when the pod does not exist", func() { + fakeClient := fake.NewFakeClientWithScheme(scheme) + r := NewFluidAppReconciler(fakeClient, fake.NullLogger(), record.NewFakeRecorder(10)) + + result, err := r.Reconcile(context.Background(), reconcile.Request{ + NamespacedName: types.NamespacedName{Name: testPodName, Namespace: testNamespace}, + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{})) + }) + + It("returns no requeue when the pod should not enter the queue", func() { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: testPodName, + Namespace: testNamespace, + }, + } + fakeClient := fake.NewFakeClientWithScheme(scheme, pod) + r := NewFluidAppReconciler(fakeClient, fake.NullLogger(), record.NewFakeRecorder(10)) + + result, err := r.Reconcile(context.Background(), reconcile.Request{ + NamespacedName: types.NamespacedName{Name: testPodName, Namespace: testNamespace}, + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{})) + }) + + It("reconciles queueable pods and returns no requeue on successful fuse unmount", func() { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: testPodName, + Namespace: testNamespace, + Labels: map[string]string{ + common.InjectServerless: common.True, + common.InjectSidecarDone: common.True, + }, + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{ + {Name: "app"}, + { + Name: common.FuseContainerName + "-0", + Lifecycle: &corev1.Lifecycle{ + PreStop: &corev1.LifecycleHandler{ + Exec: &corev1.ExecAction{Command: []string{"umount", "/mnt/fuse"}}, + }, + }, + }, + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + ContainerStatuses: []corev1.ContainerStatus{ + { + Name: "app", + State: corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{ExitCode: 0}}, + }, + { + Name: common.FuseContainerName + "-0", + State: corev1.ContainerState{Running: &corev1.ContainerStateRunning{}}, + }, + }, + }, + } + + fakeClient := fake.NewFakeClientWithScheme(scheme, pod) + r := NewFluidAppReconciler(fakeClient, fake.NullLogger(), record.NewFakeRecorder(10)) + + patches = gomonkey.ApplyFunc((*FluidAppReconcilerImplement).umountFuseSidecars, func(_ *FluidAppReconcilerImplement, gotPod *corev1.Pod) error { + Expect(gotPod.Name).To(Equal(testPodName)) + return nil + }) + + result, err := r.Reconcile(context.Background(), reconcile.Request{ + NamespacedName: types.NamespacedName{Name: testPodName, Namespace: testNamespace}, + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{})) + }) + }) + + Describe("internalReconcile", func() { + It("requeues when unmounting fuse sidecars returns an error", func() { + r := NewFluidAppReconciler(fake.NewFakeClientWithScheme(scheme), fake.NullLogger(), record.NewFakeRecorder(10)) + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: testPodName, Namespace: testNamespace}} + expectedErr := errors.New("umount failed") + + patches = gomonkey.ApplyFunc((*FluidAppReconcilerImplement).umountFuseSidecars, func(_ *FluidAppReconcilerImplement, gotPod *corev1.Pod) error { + Expect(gotPod).To(Equal(pod)) + return expectedErr + }) + + result, err := r.internalReconcile(reconcileRequestContext{ + Context: context.Background(), + Log: fake.NullLogger(), + pod: pod, + }) + + Expect(err).To(MatchError(expectedErr)) + Expect(result).To(Equal(ctrl.Result{})) + }) + + It("returns no requeue when unmounting fuse sidecars succeeds", func() { + r := NewFluidAppReconciler(fake.NewFakeClientWithScheme(scheme), fake.NullLogger(), record.NewFakeRecorder(10)) + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: testPodName, Namespace: testNamespace}} + + patches = gomonkey.ApplyFunc((*FluidAppReconcilerImplement).umountFuseSidecars, func(_ *FluidAppReconcilerImplement, gotPod *corev1.Pod) error { + Expect(gotPod).To(Equal(pod)) + return nil + }) + + result, err := r.internalReconcile(reconcileRequestContext{ + Context: context.Background(), + Log: fake.NullLogger(), + pod: pod, + }) + + 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 aeb02be53ef..775d47376b1 100644 --- a/pkg/controllers/v1alpha1/fluidapp/implement_test.go +++ b/pkg/controllers/v1alpha1/fluidapp/implement_test.go @@ -1,156 +1,142 @@ /* - 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. - You may obtain a copy of the License at +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 + 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. +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" - "testing" "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(ctx context.Context, p1, p2, p3 string, p4 []string) (stdout string, stderr string, e error) { - return "", "", nil - } - - patches := gomonkey.ApplyFunc(kubeclient.ExecCommandInContainerWithContext, 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{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{Name: "test"}}, - }, - }, - }, - wantErr: false, - }, - { - name: "test-no-mountpath", - args: args{ - 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{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: common.FuseContainerName + "-0", - Lifecycle: &corev1.Lifecycle{ - PreStop: &corev1.LifecycleHandler{ - Exec: &corev1.ExecAction{Command: []string{"umount"}}, - }, - }, - }}, - }, - }, - }, - wantErr: false, - }, - { - name: "test-mountpath", - args: args{ - 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", - }}, - }}, +var _ = Describe("FluidAppReconcilerImplement", func() { + const expectedJuiceFSMountCmd = "/mnt/jfs/juicefs-fuse" + + var patches *gomonkey.Patches + + AfterEach(func() { + if patches != nil { + patches.Reset() + } + }) + + Describe("umountFuseSidecars", func() { + It("returns nil when there is no fuse sidecar container", func() { + i := &FluidAppReconcilerImplement{Log: fake.NullLogger()} + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "test"}}}, + } + + Expect(i.umountFuseSidecars(pod)).To(Succeed()) + }) + + It("returns nil when the fuse sidecar mount path lookup is empty", func() { + i := &FluidAppReconcilerImplement{Log: fake.NullLogger()} + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "test"}} + fuseContainer := corev1.Container{Name: common.FuseContainerName + "-0"} + + patches = gomonkey.ApplyFunc(kubeclient.GetMountPathInContainer, func(corev1.Container) (string, error) { + return "", nil + }) + patches.ApplyFunc(kubeclient.ExecCommandInContainerWithContext, func(context.Context, string, string, string, []string) (string, string, error) { + Fail("ExecCommandInContainerWithContext should not be called when mount path lookup returns empty") + return "", "", nil + }) + + Expect(i.umountFuseSidecar(pod, fuseContainer)).To(Succeed()) + }) + + It("uses the container prestop command when present", func() { + patches = gomonkey.ApplyFunc(kubeclient.ExecCommandInContainerWithContext, func(_ context.Context, podName, containerName, namespace string, cmd []string) (string, string, error) { + Expect(podName).To(Equal("test")) + Expect(containerName).To(Equal(common.FuseContainerName + "-0")) + Expect(namespace).To(BeEmpty()) + Expect(cmd).To(Equal([]string{"umount"})) + return "", "", nil + }) + + i := &FluidAppReconcilerImplement{Log: fake.NullLogger()} + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: corev1.PodSpec{Containers: []corev1.Container{{ + Name: common.FuseContainerName + "-0", + Lifecycle: &corev1.Lifecycle{PreStop: &corev1.LifecycleHandler{ + Exec: &corev1.ExecAction{Command: []string{"umount"}}, + }}, + }}}, + } + + Expect(i.umountFuseSidecars(pod)).To(Succeed()) + }) + + It("derives the mount path when the fuse sidecar has no prestop", func() { + patches = gomonkey.ApplyFunc(kubeclient.ExecCommandInContainerWithContext, func(_ context.Context, _, _, _ string, cmd []string) (string, string, error) { + Expect(cmd).To(Equal([]string{"umount", expectedJuiceFSMountCmd})) + return "", "", nil + }) + + i := &FluidAppReconcilerImplement{Log: fake.NullLogger()} + 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", + }}, + }}}, + } + + Expect(i.umountFuseSidecars(pod)).To(Succeed()) + }) + + It("unmounts each fuse sidecar container", func() { + containerNames := []string{} + patches = gomonkey.ApplyFunc((*FluidAppReconcilerImplement).umountFuseSidecar, func(_ *FluidAppReconcilerImplement, _ *corev1.Pod, fuseContainer corev1.Container) error { + containerNames = append(containerNames, fuseContainer.Name) + return nil + }) + + i := &FluidAppReconcilerImplement{Log: fake.NullLogger()} + 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"}}, }, - }, - }, - wantErr: false, - }, - { - name: "test-multi-sidecar", - args: args{ - 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: common.FuseContainerName + "-1", - VolumeMounts: []corev1.VolumeMount{{ - Name: "juicefs-fuse-mount", - MountPath: "/mnt/jfs", - }}, - }, - }, + { + Name: common.FuseContainerName + "-1", + VolumeMounts: []corev1.VolumeMount{{Name: "juicefs-fuse-mount", MountPath: "/mnt/jfs"}}, }, - }, - }, - wantErr: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - i := &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) + }}, } + + Expect(i.umountFuseSidecars(pod)).To(Succeed()) + Expect(containerNames).To(ConsistOf(common.FuseContainerName+"-0", common.FuseContainerName+"-1")) + Expect(containerNames).To(HaveLen(2)) }) - } -} + }) +}) diff --git a/pkg/controllers/v1alpha1/fluidapp/suite_test.go b/pkg/controllers/v1alpha1/fluidapp/suite_test.go new file mode 100644 index 00000000000..605ad031c30 --- /dev/null +++ b/pkg/controllers/v1alpha1/fluidapp/suite_test.go @@ -0,0 +1,36 @@ +/* +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" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/fluid-cloudnative/fluid/pkg/utils/fake" +) + +func TestFluidAppController(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "FluidApp Controller Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(fake.NullLogger()) +})