Repository: fluid-cloudnative/fluid
Pull request #4490: feat: support injecting file prefetcher sidecar to app pods
Author: login: TrafalgarZZZ
Labels: name: lgtm
name: approved
Created: 2025-02-10T03:30:04Z | Merged: 2025-03-07T11:35:23Z

<!-- 
Please make sure you have read and understood the contributing guidelines;
https://github.com/fluid-cloudnative/fluid/blob/master/CONTRIBUTING.md-->

### Ⅰ. Describe what this PR does


### Ⅱ. Does this pull request fix one issue?
<!--If so, add "fixes #xxxx" so that the issue will be closed when this PR is merged (for example, "fixes #15" to close Issue #15). Otherwise, add "NONE" -->
fixes #XXXX

### Ⅲ. List the added test cases (unit test/integration test) if any, please explain if no tests are needed.


### Ⅳ. Describe how to verify it


### Ⅴ. Special notes for reviews


Diff:
diff --git a/charts/fluid/fluid/values.yaml b/charts/fluid/fluid/values.yaml
index d2092a38016..0a28cd445df 100644
--- a/charts/fluid/fluid/values.yaml
+++ b/charts/fluid/fluid/values.yaml
@@ -315,6 +315,7 @@ webhook:
           - NodeAffinityWithCache
           - MountPropagationInjector
           - DatasetUsageInjector
+          - FilePrefetcher
         withoutDataset:
           - PreferNodesWithoutCache
       # serverless webhook plugins
@@ -322,6 +323,7 @@ webhook:
         withDataset:
           - FuseSidecar
           - DatasetUsageInjector
+          - FilePrefetcher
         withoutDataset: []
     pluginConfig:
       - name: NodeAffinityWithCache
diff --git a/pkg/webhook/plugins/fileprefetcher/const.go b/pkg/webhook/plugins/fileprefetcher/const.go
new file mode 100644
index 00000000000..8309167db29
--- /dev/null
+++ b/pkg/webhook/plugins/fileprefetcher/const.go
@@ -0,0 +1,36 @@
+/*
+Copyright 2025 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 fileprefetcher
+
+// Environment variables for file prefetcher
+const (
+	envKeyFilePrefetcherFileList       = "FILE_PREFETCHER_FILE_LIST"
+	envKeyFilePrefetcherAsyncPrefetch  = "FILE_PREFETCHER_ASYNC_PREFETCH"
+	envKeyFilePrefetcherTimeoutSeconds = "FILE_PREFETCHER_TIMEOUT_SECONDS"
+
+	envKeyFilePrefetcherImage = "FILE_PREFETCHER_IMAGE"
+)
+
+// Constants for file prefetcher
+const (
+	filePrefetcherContainerName         = "fluid-file-prefetcher"
+	filePrefetcherStatusVolumeName      = "fluid-file-prefetcher-status-vol"
+	filePrefetcherStatusVolumeMountPath = "/tmp/fluid-file-prefetcher/status"
+
+	filePrefetcherDefaultFileList          = "<ALL>"
+	filePrefetcherDefaultTimeoutSecondsStr = "120"
+)
diff --git a/pkg/webhook/plugins/fileprefetcher/file_prefetcher.go b/pkg/webhook/plugins/fileprefetcher/file_prefetcher.go
new file mode 100644
index 00000000000..5da0c7fad81
--- /dev/null
+++ b/pkg/webhook/plugins/fileprefetcher/file_prefetcher.go
@@ -0,0 +1,317 @@
+/*
+Copyright 2025 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 fileprefetcher
+
+import (
+	"fmt"
+	stdlog "log"
+	"path"
+	"path/filepath"
+	"strconv"
+	"strings"
+
+	"github.com/fluid-cloudnative/fluid/pkg/common"
+	"github.com/fluid-cloudnative/fluid/pkg/ddc/base"
+	"github.com/fluid-cloudnative/fluid/pkg/utils/docker"
+	"github.com/fluid-cloudnative/fluid/pkg/webhook/plugins/api"
+	"github.com/go-logr/logr"
+	corev1 "k8s.io/api/core/v1"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+const Name string = "FilePrefetcher"
+
+var defaultFilePrefetcherImage = ""
+
+func init() {
+	imageRepo := docker.GetImageRepoFromEnv(envKeyFilePrefetcherImage)
+	imageTag := docker.GetImageTagFromEnv(envKeyFilePrefetcherImage)
+	if len(imageRepo) == 0 || len(imageTag) == 0 {
+		stdlog.Printf("WARNING: env variable %s is not set, file prefetcher image is required in Pod's annotation", envKeyFilePrefetcherImage)
+		return
+	}
+	defaultFilePrefetcherImage = fmt.Sprintf("%s:%s", imageRepo, imageTag)
+	stdlog.Printf("Found %s value %s, using it as defaultFilePrefetcherImage", envKeyFilePrefetcherImage, defaultFilePrefetcherImage)
+}
+
+var _ api.MutatingHandler = &FilePrefetcher{}
+
+type FilePrefetcher struct {
+	client client.Client
+	name   string
+	log    logr.Logger
+}
+
+func NewPlugin(c client.Client, args string) (api.MutatingHandler, error) {
+	return &FilePrefetcher{
+		client: c,
+		name:   Name,
+		log:    ctrl.Log.WithName("FilePrefetcher"),
+	}, nil
+}
+
+func (p *FilePrefetcher) GetName() string {
+	return p.name
+}
+
+func (p *FilePrefetcher) Mutate(pod *corev1.Pod, runtimeInfos map[string]base.RuntimeInfoInterface) (shouldStop bool, err error) {
+	if !common.CheckExpectValue(pod.Annotations, AnnotationFilePrefetcherInject, common.True) {
+		return false, nil
+	}
+
+	if common.CheckExpectValue(pod.Annotations, AnnotationFilePrefetcherInjectDone, common.True) {
+		return false, nil
+	}
+
+	config, err := p.buildFilePrefetcherConfig(pod, runtimeInfos)
+	if err != nil {
+		p.log.Error(err, "failed to build file prefetcher config")
+		err = fmt.Errorf("failed to build file prefetcher config: %v", err)
+		return true, err
+	}
+
+	if len(config.GlobPaths) == 0 {
+		p.log.Info("Skipping injecting file prefetcher sidecar container because there's no valid file-list defined in annotation", "annotation", AnnotationFilePrefetcherFileList)
+		return false, nil
+	}
+
+	containerSpec, statusFileVolume := p.buildFilePrefetcherSidecarContainer(config)
+	if config.AsyncPrefetch {
+		statusVolumeMount := corev1.VolumeMount{
+			Name:      filePrefetcherStatusVolumeName,
+			MountPath: filePrefetcherStatusVolumeMountPath,
+		}
+		for idx, ctr := range pod.Spec.Containers {
+			if strings.HasPrefix(ctr.Name, common.FuseContainerName) {
+				// Skip injecting file prefetcher status volume into fluid's fuse sidecar containers.
+				continue
+			}
+			pod.Spec.Containers[idx].VolumeMounts = append(pod.Spec.Containers[idx].VolumeMounts, statusVolumeMount)
+		}
+	}
+
+	// Inject file prefetcher container right after fuse sidecar containers, we assume fluid's fuse sidecar container is injected together.
+	// e.g. before injection: [C1, FUSE1, FUSE2, C2, C3], after injection: [C1, FUSE1, FUSE2, FILEPREFETCHER, C2, C3]
+	pod.Spec.Containers = p.injectFilePrefetcherSidecar(pod.Spec.Containers, containerSpec)
+	pod.Spec.Volumes = append(pod.Spec.Volumes, statusFileVolume)
+	pod.Annotations[AnnotationFilePrefetcherInjectDone] = common.True
+
+	return false, nil
+}
+
+type filePrefetcherConfig struct {
+	// Image is the image of the file prefetcher sidecar container
+	Image string
+	// AsyncPrefetch indicates whether to use async prefetching, defaulting to false
+	AsyncPrefetch bool
+	// VolumeMountPaths is a map of volume name to mount path
+	VolumeMountPaths map[string]string
+	// GlobPaths is a string indicating all the paths to prefetch. It is a semicolon-separated list of paths
+	GlobPaths string
+	// TimeoutSeconds is a int indicating the timeout for file prefetcher, defined in seconds
+	TimeoutSeconds int
+	// ExtraEnvs is a map of extra envs to inject into the file prefetcher sidecar container
+	ExtraEnvs map[string]string
+}
+
+func (p *FilePrefetcher) buildFilePrefetcherConfig(pod *corev1.Pod, runtimeInfos map[string]base.RuntimeInfoInterface) (config filePrefetcherConfig, err error) {
+	defaultFn := func(keyValues map[string]string, key string, defaultValue string) (value string) {
+		if value, ok := keyValues[key]; ok {
+			return value
+		}
+		return defaultValue
+	}
+
+	config.Image = defaultFn(pod.Annotations, AnnotationFilePrefetcherImage, defaultFilePrefetcherImage)
+	if len(config.Image) == 0 {
+		err = fmt.Errorf("file prefetcher's image is required, set it in pod's annotation \"%s=<image>\"", AnnotationFilePrefetcherImage)
+		return
+	}
+
+	extraEnvs := map[string]string{}
+	// extraEnvsStr takes the format like: '<key1>=<value1> <key2>=<value2> <key3>=<value3>'
+	extraEnvsStr := defaultFn(pod.Annotations, AnnotationFilePrefetcherExtraEnvs, "")
+	if len(extraEnvsStr) > 0 {
+		keyValuePairs := strings.Split(extraEnvsStr, " ")
+		for _, keyValuePair := range keyValuePairs {
+			kvSlice := strings.Split(keyValuePair, "=")
+			if len(kvSlice) != 2 {
+				err = fmt.Errorf("file prefetcher's extra envs is required to be '<key1>=<value1> <key2>=<value2> <key3>=<value3>', but found unexpected key-value pair: %s", keyValuePair)
+				return
+			}
+			extraEnvs[kvSlice[0]] = kvSlice[1]
+		}
+	}
+	config.ExtraEnvs = extraEnvs
+
+	fileList := defaultFn(pod.Annotations, AnnotationFilePrefetcherFileList, filePrefetcherDefaultFileList)
+	if fileList == filePrefetcherDefaultFileList {
+		pvcNames := make([]string, 0)
+		for pvcName := range runtimeInfos {
+			pvcNames = append(pvcNames, fmt.Sprintf("pvc://%s", pvcName))
+		}
+		fileList = strings.Join(pvcNames, ";")
+	}
+	volumeMountPaths, globPaths := p.parseGlobPathsFromFileList(fileList, pod, runtimeInfos)
+	config.VolumeMountPaths = volumeMountPaths
+	config.GlobPaths = strings.Join(globPaths, ";")
+
+	asyncPrefetchStr := defaultFn(pod.Annotations, AnnotationFilePrefetcherAsync, "false")
+	if asyncPrefetch, parseErr := strconv.ParseBool(asyncPrefetchStr); parseErr != nil {
+		err = fmt.Errorf("invalid value for %s: %s, must either be false or true: %v", AnnotationFilePrefetcherAsync, asyncPrefetchStr, parseErr)
+		return
+	} else {
+		config.AsyncPrefetch = asyncPrefetch
+	}
+
+	timeoutSecondsStr := defaultFn(pod.Annotations, AnnotationFilePrefetcherTimeoutSeconds, filePrefetcherDefaultTimeoutSecondsStr)
+	if timeoutSeconds, parseErr := strconv.ParseInt(timeoutSecondsStr, 10, 32); parseErr != nil {
+		err = fmt.Errorf("invalid value for %s: %s, must be of type integer: %v", AnnotationFilePrefetcherTimeoutSeconds, timeoutSecondsStr, parseErr)
+		return
+	} else {
+		config.TimeoutSeconds = int(timeoutSeconds)
+	}
+	p.log.V(1).Info("building file prefetcher config", "config", config)
+
+	return
+}
+
+func (p *FilePrefetcher) parseGlobPathsFromFileList(fileList string, pod *corev1.Pod, runtimeInfos map[string]base.RuntimeInfoInterface) (volumeMountPaths map[string]string, globPaths []string) {
+	volumeMountPaths = map[string]string{}
+	globPaths = []string{}
+
+	if len(fileList) == 0 {
+		return
+	}
+
+	uriPaths := strings.Split(fileList, ";")
+	for _, uriPath := range uriPaths {
+		if !strings.HasPrefix(uriPath, string(common.VolumeScheme)) {
+			p.log.Info("skip adding path to prefetch list because it does not start with pvc://", "path", uriPath)
+			continue
+		}
+		// e.g. uriPath="pvc://mypvc/path/to/myfolder/*.pkl" => items=["mypvc", "path", "to", "myfolder", "*.pkl"]
+		items := strings.Split(strings.TrimPrefix(uriPath, string(common.VolumeScheme)), string(filepath.Separator))
+		if len(items) == 0 {
+			p.log.Info("skip adding path to prefetch list because it does not specify a valid persistentVolumeClaim", "path", uriPath)
+			continue
+		}
+
+		var pvcName, globPath string
+		if len(items) == 1 {
+			pvcName = items[0]
+			globPath = "**"
+		} else {
+			pvcName = items[0]
+			globPath = filepath.Clean(fmt.Sprintf("%c%s", filepath.Separator, filepath.Join(items[1:]...)))
+		}
+
+		if _, ok := runtimeInfos[pvcName]; !ok {
+			p.log.Info("skip adding path to prefetch list because the persistentVolumeClaim is not managed by Fluid", "path", uriPath)
+			continue
+		}
+
+		for _, volume := range pod.Spec.Volumes {
+			if volume.PersistentVolumeClaim != nil && volume.PersistentVolumeClaim.ClaimName == pvcName {
+				volumeMountPaths[volume.Name] = path.Join("/data", volume.Name)
+				globPaths = append(globPaths, path.Join(volumeMountPaths[volume.Name], globPath))
+			}
+		}
+	}
+
+	return
+}
+
+// This function assembles and returns a file prefetcher sidecar container
+func (p *FilePrefetcher) buildFilePrefetcherSidecarContainer(config filePrefetcherConfig) (corev1.Container, corev1.Volume) {
+	volumeMounts := []corev1.VolumeMount{}
+	for volumeName, mountPath := range config.VolumeMountPaths {
+		volumeMounts = append(volumeMounts, corev1.VolumeMount{
+			Name:      volumeName,
+			MountPath: mountPath,
+		})
+	}
+
+	statusFileVolume := corev1.Volume{
+		Name: filePrefetcherStatusVolumeName,
+		VolumeSource: corev1.VolumeSource{
+			EmptyDir: &corev1.EmptyDirVolumeSource{},
+		},
+	}
+	volumeMounts = append(volumeMounts, corev1.VolumeMount{
+		Name:      statusFileVolume.Name,
+		MountPath: filePrefetcherStatusVolumeMountPath,
+	})
+
+	containerSpec := corev1.Container{
+		Name:  filePrefetcherContainerName,
+		Image: config.Image,
+		Env: []corev1.EnvVar{
+			{
+				Name:  envKeyFilePrefetcherFileList,
+				Value: config.GlobPaths,
+			},
+			{
+				Name:  envKeyFilePrefetcherAsyncPrefetch,
+				Value: strconv.FormatBool(config.AsyncPrefetch),
+			},
+			{
+				Name:  envKeyFilePrefetcherTimeoutSeconds,
+				Value: strconv.FormatInt(int64(config.TimeoutSeconds), 10),
+			},
+		},
+		VolumeMounts: volumeMounts,
+	}
+
+	for k, v := range config.ExtraEnvs {
+		containerSpec.Env = append(containerSpec.Env, corev1.EnvVar{Name: k, Value: v})
+	}
+
+	if !config.AsyncPrefetch {
+		containerSpec.Lifecycle = &corev1.Lifecycle{
+			PostStart: &corev1.LifecycleHandler{
+				Exec: &corev1.ExecAction{
+					Command: []string{
+						"bash",
+						"-c",
+						`cnt=0; while [[ $cnt -lt $FILE_PREFETCHER_TIMEOUT_SECONDS ]]; do if [[ -e "/tmp/fluid-file-prefetcher/status/prefetcher.status" ]]; then exit 0; fi; cnt=$(expr $cnt + 1); sleep 1; done; echo "time out waiting for prefetching done"; exit 1`,
+					},
+				},
+			},
+		}
+	}
+
+	return containerSpec, statusFileVolume
+}
+
+func (p *FilePrefetcher) injectFilePrefetcherSidecar(oldContainers []corev1.Container, filePrefetcherCtr corev1.Container) (newContainers []corev1.Container) {
+	lastFuseSidecarIndex := -1
+	for idx, ctr := range oldContainers {
+		if strings.HasPrefix(ctr.Name, common.FuseContainerName) {
+			lastFuseSidecarIndex = idx
+		}
+	}
+
+	// Insert file prefetcher sidecar after
+	newContainers = make([]corev1.Container, 0, len(oldContainers)+1)
+	newContainers = append(newContainers, oldContainers[:lastFuseSidecarIndex+1]...)
+	newContainers = append(newContainers, filePrefetcherCtr)
+	newContainers = append(newContainers, oldContainers[lastFuseSidecarIndex+1:]...)
+
+	return newContainers
+}
diff --git a/pkg/webhook/plugins/fileprefetcher/file_prefetcher_test.go b/pkg/webhook/plugins/fileprefetcher/file_prefetcher_test.go
new file mode 100644
index 00000000000..db7ac69b709
--- /dev/null
+++ b/pkg/webhook/plugins/fileprefetcher/file_prefetcher_test.go
@@ -0,0 +1,423 @@
+/*
+Copyright 2025 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 fileprefetcher
+
+import (
+	"testing"
+
+	"github.com/fluid-cloudnative/fluid/pkg/common"
+	"github.com/fluid-cloudnative/fluid/pkg/ddc/base"
+	"github.com/stretchr/testify/assert"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+)
+
+func TestInjectFilePrefetcherSidecar_WithMatchingContainers_ShouldInsertAfterLastMatch(t *testing.T) {
+	oldContainers := []corev1.Container{
+		{Name: common.FuseContainerName + "-2"},
+		{Name: common.FuseContainerName + "-1"},
+		{Name: "other-container"},
+		{Name: "another-container"},
+	}
+	prefetcher := &FilePrefetcher{}
+	filePrefetcherCtr := corev1.Container{Name: "file-prefetcher-ctr"}
+
+	newContainers := prefetcher.injectFilePrefetcherSidecar(oldContainers, filePrefetcherCtr)
+
+	assert.Equal(t, 5, len(newContainers))
+	assert.Equal(t, common.FuseContainerName+"-2", newContainers[0].Name)
+	assert.Equal(t, common.FuseContainerName+"-1", newContainers[1].Name)
+	assert.Equal(t, "file-prefetcher-ctr", newContainers[2].Name)
+	assert.Equal(t, "other-container", newContainers[3].Name)
+	assert.Equal(t, "another-container", newContainers[4].Name)
+}
+
+func TestInjectFilePrefetcherSidecar_WithoutMatchingContainers_ShouldInsertAtBeginning(t *testing.T) {
+	oldContainers := []corev1.Container{
+		{Name: "other-container"},
+		{Name: "another-container"},
+	}
+	prefetcher := &FilePrefetcher{}
+	filePrefetcherCtr := corev1.Container{Name: "file-prefetcher-ctr"}
+
+	newContainers := prefetcher.injectFilePrefetcherSidecar(oldContainers, filePrefetcherCtr)
+
+	assert.Equal(t, 3, len(newContainers))
+	assert.Equal(t, "file-prefetcher-ctr", newContainers[0].Name)
+	assert.Equal(t, "other-container", newContainers[1].Name)
+	assert.Equal(t, "another-container", newContainers[2].Name)
+}
+
+func TestInjectFilePrefetcherSidecar_EmptyContainerList_ShouldOnlyContainFilePrefetcher(t *testing.T) {
+	oldContainers := []corev1.Container{}
+	filePrefetcherCtr := corev1.Container{Name: "file-prefetcher-ctr"}
+
+	prefetcher := &FilePrefetcher{}
+	newContainers := prefetcher.injectFilePrefetcherSidecar(oldContainers, filePrefetcherCtr)
+
+	assert.Equal(t, 1, len(newContainers))
+	assert.Equal(t, "file-prefetcher-ctr", newContainers[0].Name)
+}
+
+var _ = Describe("buildFilePrefetcherConfig", func() {
+	var (
+		pod          *corev1.Pod
+		runtimeInfos map[string]base.RuntimeInfoInterface
+		prefetcher   *FilePrefetcher
+	)
+
+	BeforeEach(func() {
+		pod = &corev1.Pod{
+			ObjectMeta: metav1.ObjectMeta{
+				Annotations: map[string]string{},
+			},
+		}
+		runtimeInfos = map[string]base.RuntimeInfoInterface{}
+		prefetcher = &FilePrefetcher{}
+	})
+
+	Context("when all annotations are provided correctly", func() {
+		BeforeEach(func() {
+			pod.Annotations[AnnotationFilePrefetcherImage] = "test-image"
+			pod.Annotations[AnnotationFilePrefetcherExtraEnvs] = "KEY1=VALUE1 KEY2=VALUE2"
+			pod.Annotations[AnnotationFilePrefetcherFileList] = "pvc://mypvc/path/to/myfolder/*.pkl"
+			pod.Annotations[AnnotationFilePrefetcherAsync] = "true"
+			pod.Annotations[AnnotationFilePrefetcherTimeoutSeconds] = "30"
+			pod.Spec.Volumes = append(pod.Spec.Volumes, corev1.Volume{Name: "myvol", VolumeSource: corev1.VolumeSource{
+				PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
+					ClaimName: "mypvc",
+				},
+			}})
+			runtimeInfos["mypvc"] = &base.RuntimeInfo{}
+		})
+
+		It("should build the configuration successfully", func() {
+			config, err := prefetcher.buildFilePrefetcherConfig(pod, runtimeInfos)
+			Expect(err).NotTo(HaveOccurred())
+			Expect(config.Image).To(Equal("test-image"))
+			Expect(config.ExtraEnvs).To(HaveKeyWithValue("KEY1", "VALUE1"))
+			Expect(config.ExtraEnvs).To(HaveKeyWithValue("KEY2", "VALUE2"))
+			Expect(config.GlobPaths).To(Equal("/data/myvol/path/to/myfolder/*.pkl"))
+			Expect(config.AsyncPrefetch).To(BeTrue())
+			Expect(config.TimeoutSeconds).To(Equal(30))
+		})
+
+	})
+
+	Context("when the image annotation is missing, with default image set", func() {
+		BeforeEach(func() {
+			pod.Annotations[AnnotationFilePrefetcherExtraEnvs] = "KEY1=VALUE1 KEY2=VALUE2"
+			pod.Annotations[AnnotationFilePrefetcherFileList] = "pvc://mypvc/path/to/myfolder/*.pkl"
+			pod.Annotations[AnnotationFilePrefetcherAsync] = "true"
+			pod.Annotations[AnnotationFilePrefetcherTimeoutSeconds] = "30"
+			pod.Spec.Volumes = append(pod.Spec.Volumes, corev1.Volume{Name: "myvol", VolumeSource: corev1.VolumeSource{
+				PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
+					ClaimName: "mypvc",
+				},
+			}})
+			runtimeInfos["mypvc"] = &base.RuntimeInfo{}
+			defaultFilePrefetcherImage = "test-image"
+		})
+
+		It("should use the default image", func() {
+			config, err := prefetcher.buildFilePrefetcherConfig(pod, runtimeInfos)
+			Expect(err).NotTo(HaveOccurred())
+			Expect(config.Image).To(Equal(defaultFilePrefetcherImage))
+			Expect(config.ExtraEnvs).To(HaveKeyWithValue("KEY1", "VALUE1"))
+			Expect(config.ExtraEnvs).To(HaveKeyWithValue("KEY2", "VALUE2"))
+			Expect(config.GlobPaths).To(Equal("/data/myvol/path/to/myfolder/*.pkl"))
+			Expect(config.AsyncPrefetch).To(BeTrue())
+			Expect(config.TimeoutSeconds).To(Equal(30))
+		})
+	})
+
+	Context("when the image annotation is missing, no default image set", func() {
+		BeforeEach(func() {
+			pod.Annotations[AnnotationFilePrefetcherExtraEnvs] = "KEY1=VALUE1 KEY2=VALUE2"
+			pod.Annotations[AnnotationFilePrefetcherFileList] = "pvc://mypvc/path/to/myfolder/*.pkl"
+			pod.Annotations[AnnotationFilePrefetcherAsync] = "true"
+			pod.Annotations[AnnotationFilePrefetcherTimeoutSeconds] = "30"
+			defaultFilePrefetcherImage = ""
+		})
+
+		It("should return an error", func() {
+			_, err := prefetcher.buildFilePrefetcherConfig(pod, runtimeInfos)
+			Expect(err).To(HaveOccurred())
+		})
+	})
+
+	Context("when the extra envs annotation has an invalid format", func() {
+		BeforeEach(func() {
+			pod.Annotations[AnnotationFilePrefetcherImage] = "test-image"
+			pod.Annotations[AnnotationFilePrefetcherExtraEnvs] = "KEY1=VALUE1 KEY2"
+			pod.Annotations[AnnotationFilePrefetcherFileList] = "pvc://mypvc/path/to/myfolder/*.pkl"
+			pod.Annotations[AnnotationFilePrefetcherAsync] = "true"
+			pod.Annotations[AnnotationFilePrefetcherTimeoutSeconds] = "30"
+		})
+
+		It("should return an error", func() {
+			_, err := prefetcher.buildFilePrefetcherConfig(pod, runtimeInfos)
+			Expect(err).To(HaveOccurred())
+		})
+	})
+
+	Context("when the async prefetch annotation has an invalid value", func() {
+		BeforeEach(func() {
+			pod.Annotations[AnnotationFilePrefetcherImage] = "test-image"
+			pod.Annotations[AnnotationFilePrefetcherExtraEnvs] = "KEY1=VALUE1 KEY2=VALUE2"
+			pod.Annotations[AnnotationFilePrefetcherFileList] = "pvc://mypvc/path/to/myfolder/*.pkl"
+			pod.Annotations[AnnotationFilePrefetcherAsync] = "invalid"
+			pod.Annotations[AnnotationFilePrefetcherTimeoutSeconds] = "30"
+		})
+
+		It("should return an error", func() {
+			_, err := prefetcher.buildFilePrefetcherConfig(pod, runtimeInfos)
+			Expect(err).To(HaveOccurred())
+		})
+	})
+
+	Context("when the timeout seconds annotation has an invalid value", func() {
+		BeforeEach(func() {
+			pod.Annotations[AnnotationFilePrefetcherImage] = "test-image"
+			pod.Annotations[AnnotationFilePrefetcherExtraEnvs] = "KEY1=VALUE1 KEY2=VALUE2"
+			pod.Annotations[AnnotationFilePrefetcherFileList] = "pvc://mypvc/path/to/myfolder/*.pkl"
+			pod.Annotations[AnnotationFilePrefetcherAsync] = "true"
+			pod.Annotations[AnnotationFilePrefetcherTimeoutSeconds] = "invalid"
+		})
+
+		It("should return an error", func() {
+			_, err := prefetcher.buildFilePrefetcherConfig(pod, runtimeInfos)
+			Expect(err).To(HaveOccurred())
+		})
+	})
+})
+
+var _ = Describe("parseGlobPathsFromFileList", func() {
+	var (
+		pod          *corev1.Pod
+		runtimeInfos map[string]base.RuntimeInfoInterface
+		prefetcher   *FilePrefetcher
+	)
+
+	BeforeEach(func() {
+		pod = &corev1.Pod{
+			ObjectMeta: metav1.ObjectMeta{
+				Annotations: map[string]string{},
+			},
+			Spec: corev1.PodSpec{
+				Volumes: []corev1.Volume{
+					{
+						Name: "mypvc-volume",
+						VolumeSource: corev1.VolumeSource{
+							PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
+								ClaimName: "mypvc",
+							},
+						},
+					},
+				},
+			},
+		}
+		runtimeInfos = map[string]base.RuntimeInfoInterface{
+			"mypvc": &base.RuntimeInfo{},
+		}
+		prefetcher = &FilePrefetcher{}
+	})
+
+	Context("when the file list is valid", func() {
+		BeforeEach(func() {
+			fileList := "pvc://mypvc/path/to/myfolder/*.pkl"
+			pod.Annotations[AnnotationFilePrefetcherFileList] = fileList
+		})
+
+		It("should parse the glob paths correctly", func() {
+			volumeMountPaths, globPaths := prefetcher.parseGlobPathsFromFileList(pod.Annotations[AnnotationFilePrefetcherFileList], pod, runtimeInfos)
+			Expect(volumeMountPaths).To(HaveKeyWithValue("mypvc-volume", "/data/mypvc-volume"))
+			Expect(globPaths).To(ConsistOf("/data/mypvc-volume/path/to/myfolder/*.pkl"))
+		})
+	})
+
+	Context("when the file list contains multiple valid paths", func() {
+		BeforeEach(func() {
+			fileList := "pvc://mypvc/path/to/myfolder/*.pkl;pvc://mypvc/another/path/*.txt"
+			pod.Annotations[AnnotationFilePrefetcherFileList] = fileList
+		})
+
+		It("should parse the glob paths correctly", func() {
+			volumeMountPaths, globPaths := prefetcher.parseGlobPathsFromFileList(pod.Annotations[AnnotationFilePrefetcherFileList], pod, runtimeInfos)
+			Expect(volumeMountPaths).To(HaveKeyWithValue("mypvc-volume", "/data/mypvc-volume"))
+			Expect(globPaths).To(ConsistOf("/data/mypvc-volume/path/to/myfolder/*.pkl", "/data/mypvc-volume/another/path/*.txt"))
+		})
+	})
+
+	Context("when the file list contains an invalid URI path", func() {
+		BeforeEach(func() {
+			fileList := "invalid://mypvc/path/to/myfolder/*.pkl"
+			pod.Annotations[AnnotationFilePrefetcherFileList] = fileList
+		})
+
+		It("should skip the invalid path", func() {
+			volumeMountPaths, globPaths := prefetcher.parseGlobPathsFromFileList(pod.Annotations[AnnotationFilePrefetcherFileList], pod, runtimeInfos)
+			Expect(volumeMountPaths).To(BeEmpty())
+			Expect(globPaths).To(BeEmpty())
+		})
+	})
+
+	Context("when the file list contains a path without a valid PVC", func() {
+		BeforeEach(func() {
+			fileList := "pvc://nonexistent-pvc/path/to/myfolder/*.pkl"
+			pod.Annotations[AnnotationFilePrefetcherFileList] = fileList
+		})
+
+		It("should skip the path with the nonexistent PVC", func() {
+			volumeMountPaths, globPaths := prefetcher.parseGlobPathsFromFileList(pod.Annotations[AnnotationFilePrefetcherFileList], pod, runtimeInfos)
+			Expect(volumeMountPaths).To(BeEmpty())
+			Expect(globPaths).To(BeEmpty())
+		})
+	})
+
+	Context("when the file list is empty", func() {
+		BeforeEach(func() {
+			fileList := ""
+			pod.Annotations[AnnotationFilePrefetcherFileList] = fileList
+		})
+
+		It("should return empty volume mount paths and glob paths", func() {
+			volumeMountPaths, globPaths := prefetcher.parseGlobPathsFromFileList(pod.Annotations[AnnotationFilePrefetcherFileList], pod, runtimeInfos)
+			Expect(volumeMountPaths).To(BeEmpty())
+			Expect(globPaths).To(BeEmpty())
+		})
+	})
+
+	Context("when the file list contains a path without a subpath", func() {
+		BeforeEach(func() {
+			fileList := "pvc://mypvc"
+			pod.Annotations[AnnotationFilePrefetcherFileList] = fileList
+		})
+
+		It("should default the glob path to **", func() {
+			volumeMountPaths, globPaths := prefetcher.parseGlobPathsFromFileList(pod.Annotations[AnnotationFilePrefetcherFileList], pod, runtimeInfos)
+			Expect(volumeMountPaths).To(HaveKeyWithValue("mypvc-volume", "/data/mypvc-volume"))
+			Expect(globPaths).To(ConsistOf("/data/mypvc-volume/**"))
+		})
+	})
+
+	Context("when the file list contains relative path like \"..\" and \".\"", func() {
+		BeforeEach(func() {
+			fileList := "pvc://mypvc/../path/to/myfolder/.././mydir*/test1"
+			pod.Annotations[AnnotationFilePrefetcherFileList] = fileList
+		})
+
+		It("should clean the file list", func() {
+			volumeMountPaths, globPaths := prefetcher.parseGlobPathsFromFileList(pod.Annotations[AnnotationFilePrefetcherFileList], pod, runtimeInfos)
+			Expect(volumeMountPaths).To(HaveKeyWithValue("mypvc-volume", "/data/mypvc-volume"))
+			Expect(globPaths).To(ConsistOf("/data/mypvc-volume/path/to/mydir*/test1"))
+		})
+	})
+})
+
+var _ = Describe("buildFilePrefetcherSidecarContainer", func() {
+	var (
+		config     filePrefetcherConfig
+		prefetcher *FilePrefetcher
+	)
+
+	BeforeEach(func() {
+		prefetcher = &FilePrefetcher{}
+		config = filePrefetcherConfig{
+			Image:            "test-image",
+			AsyncPrefetch:    false,
+			VolumeMountPaths: map[string]string{"mypvc-volume": "/data/mypvc-volume"},
+			GlobPaths:        "/data/mypvc-volume/path/to/myfolder/*.pkl",
+			TimeoutSeconds:   30,
+			ExtraEnvs:        map[string]string{"KEY1": "VALUE1", "KEY2": "VALUE2"},
+		}
+	})
+
+	Context("when the configuration is valid", func() {
+		It("should build the sidecar container correctly", func() {
+			containerSpec, statusFileVolume := prefetcher.buildFilePrefetcherSidecarContainer(config)
+			Expect(containerSpec.Name).To(Equal(filePrefetcherContainerName))
+			Expect(containerSpec.Image).To(Equal("test-image"))
+			Expect(containerSpec.Env).To(ContainElements(
+				corev1.EnvVar{Name: envKeyFilePrefetcherFileList, Value: "/data/mypvc-volume/path/to/myfolder/*.pkl"},
+				corev1.EnvVar{Name: envKeyFilePrefetcherAsyncPrefetch, Value: "false"},
+				corev1.EnvVar{Name: envKeyFilePrefetcherTimeoutSeconds, Value: "30"},
+				corev1.EnvVar{Name: "KEY1", Value: "VALUE1"},
+				corev1.EnvVar{Name: "KEY2", Value: "VALUE2"},
+			))
+			Expect(containerSpec.VolumeMounts).To(ContainElements(
+				corev1.VolumeMount{Name: "mypvc-volume", MountPath: "/data/mypvc-volume"},
+				corev1.VolumeMount{Name: filePrefetcherStatusVolumeName, MountPath: filePrefetcherStatusVolumeMountPath},
+			))
+			Expect(statusFileVolume.Name).To(Equal(filePrefetcherStatusVolumeName))
+			Expect(statusFileVolume.VolumeSource.EmptyDir).NotTo(BeNil())
+		})
+	})
+
+	Context("when async prefetch is enabled", func() {
+		BeforeEach(func() {
+			config.AsyncPrefetch = true
+		})
+
+		It("should not have a PostStart lifecycle hook", func() {
+			containerSpec, _ := prefetcher.buildFilePrefetcherSidecarContainer(config)
+			Expect(containerSpec.Lifecycle).To(BeNil())
+		})
+	})
+
+	Context("when async prefetch is disabled", func() {
+		BeforeEach(func() {
+			config.AsyncPrefetch = false
+		})
+
+		It("should have a PostStart lifecycle hook", func() {
+			containerSpec, _ := prefetcher.buildFilePrefetcherSidecarContainer(config)
+			Expect(containerSpec.Lifecycle).NotTo(BeNil())
+			Expect(containerSpec.Lifecycle.PostStart.Exec.Command).To(Equal([]string{
+				"bash",
+				"-c",
+				`cnt=0; while [[ $cnt -lt $FILE_PREFETCHER_TIMEOUT_SECONDS ]]; do if [[ -e "/tmp/fluid-file-prefetcher/status/prefetcher.status" ]]; then exit 0; fi; cnt=$(expr $cnt + 1); sleep 1; done; echo "time out waiting for prefetching done"; exit 1`,
+			}))
+		})
+	})
+
+	Context("when there are no extra environment variables", func() {
+		BeforeEach(func() {
+			config.ExtraEnvs = map[string]string{}
+		})
+
+		It("should not include extra environment variables", func() {
+			containerSpec, _ := prefetcher.buildFilePrefetcherSidecarContainer(config)
+			Expect(containerSpec.Env).To(ContainElements(
+				corev1.EnvVar{Name: envKeyFilePrefetcherFileList, Value: "/data/mypvc-volume/path/to/myfolder/*.pkl"},
+				corev1.EnvVar{Name: envKeyFilePrefetcherAsyncPrefetch, Value: "false"},
+				corev1.EnvVar{Name: envKeyFilePrefetcherTimeoutSeconds, Value: "30"},
+			))
+			Expect(containerSpec.Env).NotTo(ContainElement(corev1.EnvVar{Name: "KEY1", Value: "VALUE1"}))
+			Expect(containerSpec.Env).NotTo(ContainElement(corev1.EnvVar{Name: "KEY2", Value: "VALUE2"}))
+		})
+	})
+})
+
+func TestFilePrefetcher(t *testing.T) {
+
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "FilePrefetcher")
+}
diff --git a/pkg/webhook/plugins/fileprefetcher/labels.go b/pkg/webhook/plugins/fileprefetcher/labels.go
new file mode 100644
index 00000000000..5e9fe52bdba
--- /dev/null
+++ b/pkg/webhook/plugins/fileprefetcher/labels.go
@@ -0,0 +1,54 @@
+/*
+Copyright 2025 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 fileprefetcher
+
+import "github.com/fluid-cloudnative/fluid/pkg/common"
+
+const (
+	// prefix for labels and annotations used in file prefetcher feature
+	// i.e. file-prefetcher.fluid.io/
+	LabelAnnotationFilePrefetcherPrefix = "file-prefetcher." + common.LabelAnnotationPrefix
+
+	// annotation to inject file prefetcher sidecar container
+	// i.e. file-prefetcher.fluid.io/inject
+	AnnotationFilePrefetcherInject = LabelAnnotationFilePrefetcherPrefix + "inject"
+
+	// annotation to mark file prefetcher sidecar container has been injected
+	// i.e. file-prefetcher.fluid.io/inject-done
+	AnnotationFilePrefetcherInjectDone = LabelAnnotationFilePrefetcherPrefix + "inject-done"
+
+	// annotation to set file prefetcher sidecar container's image
+	// i.e. file-prefetcher.fluid.io/image
+	AnnotationFilePrefetcherImage = LabelAnnotationFilePrefetcherPrefix + "image"
+
+	// annotation to set file list to prefetch
+	// i.e. file-prefetcher.fluid.io/file-list
+	AnnotationFilePrefetcherFileList = LabelAnnotationFilePrefetcherPrefix + "file-list"
+
+	// annotation to set if prefetch files asynchronously. When setting it to true,
+	// app container will start up after file prefetcher finishes.
+	// i.e. file-prefetcher.fluid.io/async-prefetch
+	AnnotationFilePrefetcherAsync = LabelAnnotationFilePrefetcherPrefix + "async-prefetch"
+
+	// annotation to set extra envs for file prefetcher
+	// i.e. file-prefetcher.fluid.io/extra-envs
+	AnnotationFilePrefetcherExtraEnvs = LabelAnnotationFilePrefetcherPrefix + "extra-envs"
+
+	// annotation to set timeout for file prefetcher
+	// i.e. file-prefetcher.fluid.io/prefetch-timeout-seconds
+	AnnotationFilePrefetcherTimeoutSeconds = LabelAnnotationFilePrefetcherPrefix + "prefetch-timeout-seconds"
+)
diff --git a/pkg/webhook/plugins/plugins_impl.go b/pkg/webhook/plugins/plugins_impl.go
index 1a1aa0f016a..b7ef312f843 100644
--- a/pkg/webhook/plugins/plugins_impl.go
+++ b/pkg/webhook/plugins/plugins_impl.go
@@ -24,6 +24,7 @@ import (
 	"github.com/fluid-cloudnative/fluid/pkg/common"
 	"github.com/fluid-cloudnative/fluid/pkg/webhook/plugins/api"
 	"github.com/fluid-cloudnative/fluid/pkg/webhook/plugins/datasetusageinjector"
+	"github.com/fluid-cloudnative/fluid/pkg/webhook/plugins/fileprefetcher"
 	"github.com/fluid-cloudnative/fluid/pkg/webhook/plugins/fusesidecar"
 	"github.com/fluid-cloudnative/fluid/pkg/webhook/plugins/mountpropagationinjector"
 	"github.com/fluid-cloudnative/fluid/pkg/webhook/plugins/nodeaffinitywithcache"
@@ -50,6 +51,7 @@ func RegisterMutatingHandlers(client client.Client) error {
 	_ = registry.Register(nodeaffinitywithcache.Name, nodeaffinitywithcache.NewPlugin)
 	_ = registry.Register(fusesidecar.Name, fusesidecar.NewPlugin)
 	_ = registry.Register(datasetusageinjector.Name, datasetusageinjector.NewPlugin)
+	_ = registry.Register(fileprefetcher.Name, fileprefetcher.NewPlugin)
 
 	// get the handlers through the config file
 	data, err := os.ReadFile(common.WebhookPluginFilePath)
diff --git a/tools/file-prefetcher/Dockerfile b/tools/file-prefetcher/Dockerfile
new file mode 100644
index 00000000000..4fc055de698
--- /dev/null
+++ b/tools/file-prefetcher/Dockerfile
@@ -0,0 +1,13 @@
+FROM debian:bookworm-slim
+
+RUN apt update && apt install -y python3
+
+COPY ./main.py /root/main.py
+
+COPY ./entrypoint.sh /root/entrypoint.sh
+
+RUN chmod u+x /root/entrypoint.sh
+
+ENV PYTHONUNBUFFERED=1
+
+ENTRYPOINT ["/root/entrypoint.sh"]
\ No newline at end of file
diff --git a/tools/file-prefetcher/entrypoint.sh b/tools/file-prefetcher/entrypoint.sh
new file mode 100644
index 00000000000..5f8d8f96de1
--- /dev/null
+++ b/tools/file-prefetcher/entrypoint.sh
@@ -0,0 +1,7 @@
+#!/bin/bash
+
+if [[ ! -e "/tmp/fluid-file-prefetcher/status/prefetcher.status" ]]; then
+    python3 /root/main.py
+fi
+
+exec sleep inf
\ No newline at end of file
diff --git a/tools/file-prefetcher/main.py b/tools/file-prefetcher/main.py
new file mode 100644
index 00000000000..630a96e8dc3
--- /dev/null
+++ b/tools/file-prefetcher/main.py
@@ -0,0 +1,48 @@
+import os
+import glob
+import time
+
+buffer_size_in_bytes = int(os.getenv("BUFFER_SIZE_IN_BYTES", "16777216"))# 16MiB
+
+def file_read(file):
+    print("")
+    buffer_size = buffer_size_in_bytes
+    with open(file, 'rb') as f:
+        while True:
+            buffer = f.read(buffer_size)
+            if not buffer:
+                break
+
+def main():
+    glob_patterns = os.getenv("FILE_PREFETCHER_FILE_LIST", None)
+    assert glob_patterns is not None, "env variable FILE_PREFETCHER_FILE_LIST is not set"
+
+     # Get all files to prefetch
+    files_to_prefetch = []
+    glob_patterns = glob_patterns.split(";")
+    for glob_pattern in glob_patterns:
+        if os.path.isdir(glob_pattern):
+            glob_pattern = os.path.join(glob_pattern, "**")
+        files = glob.glob(glob_pattern, recursive=True)
+        files_to_prefetch.extend([file for file in files if os.path.isfile(file)])
+
+    print(f"Found {len(files_to_prefetch)} files to prefetch")
+    start_time = time.time()
+    for file in files_to_prefetch:
+        file_start_time = time.time()
+        file_read(file)
+        print(f"Prefetching file {file} end in {time.time() - file_start_time:.2f} seconds")
+    print(f"Total time: {time.time() - start_time:.2f} seconds")
+
+if __name__ == '__main__':
+    prefetch_result = ""
+    try:
+        main()
+        prefetch_result = "success"
+    except Exception as e:
+        print(e)
+        prefetch_result = "fail"
+    finally:
+        os.makedirs("/tmp/fluid-file-prefetcher/status/", exist_ok=True)
+        with open("/tmp/fluid-file-prefetcher/status/prefetcher.status", "w") as f:
+            f.write(f"prefetch_result={prefetch_result}\n")
\ No newline at end of file


Reviews:
user: login: cheyang
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-10T05:56:43Z
user: login: github-advanced-security[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-02-10T09:31:31Z
user: login: TrafalgarZZZ
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-10T09:38:36Z
user: login: cheyang
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-17T09:59:04Z
user: login: cheyang
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-03-01T07:34:07Z
user: login: TrafalgarZZZ
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-06T08:28:50Z
user: login: cheyang
body: /lgtm
/approve
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-07T11:25:39Z

Review comments:
diff_hunk: @@ -0,0 +1,20 @@
+package fileprefetcher
path: pkg/webhook/plugins/fileprefetcher/const.go
user: login: cheyang
body: Please add license header. 
created_at: 2025-02-10T05:56:43Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,13 @@
+FROM debian:bookworm-slim
+
+RUN apt update && apt install -y python3
+
+ADD ./main.py /root/main.py
path: tools/file-prefetcher/Dockerfile
user: login: github-advanced-security[bot]
body: ## Prefer COPY over ADD for copying local resources

<!--SONAR_ISSUE_KEY:AZTvMveTSZFjqIdXKow_-->Replace this ADD instruction with a COPY instruction. <p>See more on <a href="https://sonarcloud.io/project/issues?id=fluid-cloudnative_fluid&issues=AZTvMveTSZFjqIdXKow_&open=AZTvMveTSZFjqIdXKow_&pullRequest=4490">SonarQube Cloud</a></p>

[Show more details](https://github.com/fluid-cloudnative/fluid/security/code-scanning/1339)
created_at: 2025-02-10T09:31:31Z
author_association: NONE
diff_hunk: @@ -0,0 +1,13 @@
+FROM debian:bookworm-slim
+
+RUN apt update && apt install -y python3
+
+ADD ./main.py /root/main.py
+
+ADD ./entrypoint.sh /root/entrypoint.sh
path: tools/file-prefetcher/Dockerfile
user: login: github-advanced-security[bot]
body: ## Prefer COPY over ADD for copying local resources

<!--SONAR_ISSUE_KEY:AZTvMveTSZFjqIdXKoxA-->Replace this ADD instruction with a COPY instruction. <p>See more on <a href="https://sonarcloud.io/project/issues?id=fluid-cloudnative_fluid&issues=AZTvMveTSZFjqIdXKoxA&open=AZTvMveTSZFjqIdXKoxA&pullRequest=4490">SonarQube Cloud</a></p>

[Show more details](https://github.com/fluid-cloudnative/fluid/security/code-scanning/1340)
created_at: 2025-02-10T09:31:31Z
author_association: NONE
diff_hunk: @@ -0,0 +1,20 @@
+package fileprefetcher
path: pkg/webhook/plugins/fileprefetcher/const.go
user: login: TrafalgarZZZ
body: Done
created_at: 2025-02-10T09:38:36Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,316 @@
+/*
+Copyright 2025 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 fileprefetcher
+
+import (
+	"fmt"
+	stdlog "log"
+	"path"
+	"strconv"
+	"strings"
+
+	"github.com/fluid-cloudnative/fluid/pkg/common"
+	"github.com/fluid-cloudnative/fluid/pkg/ddc/base"
+	"github.com/fluid-cloudnative/fluid/pkg/utils/docker"
+	"github.com/fluid-cloudnative/fluid/pkg/webhook/plugins/api"
+	"github.com/go-logr/logr"
+	corev1 "k8s.io/api/core/v1"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+const Name string = "FilePrefetcher"
+
+var defaultFilePrefetcherImage = ""
+
+func init() {
+	imageRepo := docker.GetImageRepoFromEnv(envKeyFilePrefetcherImage)
+	imageTag := docker.GetImageTagFromEnv(envKeyFilePrefetcherImage)
+	if len(imageRepo) == 0 || len(imageTag) == 0 {
+		stdlog.Printf("WARNING: env variable %s is not set, file prefetcher image is required in Pod's annotation", envKeyFilePrefetcherImage)
+		return
+	}
+	defaultFilePrefetcherImage = fmt.Sprintf("%s:%s", imageRepo, imageTag)
+	stdlog.Printf("Found %s value %s, using it as defaultFilePrefetcherImage", envKeyFilePrefetcherImage, defaultFilePrefetcherImage)
+}
+
+var _ api.MutatingHandler = &FilePrefetcher{}
+
+type FilePrefetcher struct {
+	client client.Client
+	name   string
+	log    logr.Logger
+}
+
+func NewPlugin(c client.Client, args string) (api.MutatingHandler, error) {
+	return &FilePrefetcher{
+		client: c,
+		name:   Name,
+		log:    ctrl.Log.WithName("FilePrefetcher"),
+	}, nil
+}
+
+func (p *FilePrefetcher) GetName() string {
+	return p.name
+}
+
+func (p *FilePrefetcher) Mutate(pod *corev1.Pod, runtimeInfos map[string]base.RuntimeInfoInterface) (shouldStop bool, err error) {
+	if !common.CheckExpectValue(pod.Annotations, AnnotationFilePrefetcherInject, common.True) {
+		return false, nil
+	}
+
+	if common.CheckExpectValue(pod.Annotations, AnnotationFilePrefetcherInjectDone, common.True) {
+		return false, nil
+	}
+
+	config, err := p.buildFilePrefetcherConfig(pod, runtimeInfos)
+	if err != nil {
+		p.log.Error(err, "failed to build file prefetcher config")
+		err = fmt.Errorf("failed to build file prefetcher config: %v", err)
+		return true, err
+	}
+
+	if len(config.GlobPaths) == 0 {
+		p.log.Info("Skipping injecting file prefetcher sidecar container because there's no valid file-list defined in annotation", "annotation", AnnotationFilePrefetcherFileList)
+		return false, nil
+	}
+
+	containerSpec, statusFileVolume := p.buildFilePrefetcherSidecarContainer(config)
+	if config.AsyncPrefetch {
+		statusVolumeMount := corev1.VolumeMount{
+			Name:      filePrefetcherStatusVolumeName,
+			MountPath: filePrefetcherStatusVolumeMountPath,
+		}
+		for _, ctr := range pod.Spec.Containers {
+			if strings.HasPrefix(ctr.Name, common.FuseContainerName) {
+				// Skip injecting file prefetcher status volume into fluid's fuse sidecar containers.
+				continue
+			}
+			ctr.VolumeMounts = append(ctr.VolumeMounts, statusVolumeMount)
path: pkg/webhook/plugins/fileprefetcher/file_prefetcher.go
user: login: cheyang
body: `ctr` is a copy, so modifications do not affect the original Pod. 
created_at: 2025-02-17T09:59:04Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,316 @@
+/*
+Copyright 2025 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 fileprefetcher
+
+import (
+	"fmt"
+	stdlog "log"
+	"path"
+	"strconv"
+	"strings"
+
+	"github.com/fluid-cloudnative/fluid/pkg/common"
+	"github.com/fluid-cloudnative/fluid/pkg/ddc/base"
+	"github.com/fluid-cloudnative/fluid/pkg/utils/docker"
+	"github.com/fluid-cloudnative/fluid/pkg/webhook/plugins/api"
+	"github.com/go-logr/logr"
+	corev1 "k8s.io/api/core/v1"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+const Name string = "FilePrefetcher"
+
+var defaultFilePrefetcherImage = ""
+
+func init() {
+	imageRepo := docker.GetImageRepoFromEnv(envKeyFilePrefetcherImage)
+	imageTag := docker.GetImageTagFromEnv(envKeyFilePrefetcherImage)
+	if len(imageRepo) == 0 || len(imageTag) == 0 {
+		stdlog.Printf("WARNING: env variable %s is not set, file prefetcher image is required in Pod's annotation", envKeyFilePrefetcherImage)
+		return
+	}
+	defaultFilePrefetcherImage = fmt.Sprintf("%s:%s", imageRepo, imageTag)
+	stdlog.Printf("Found %s value %s, using it as defaultFilePrefetcherImage", envKeyFilePrefetcherImage, defaultFilePrefetcherImage)
+}
+
+var _ api.MutatingHandler = &FilePrefetcher{}
+
+type FilePrefetcher struct {
+	client client.Client
+	name   string
+	log    logr.Logger
+}
+
+func NewPlugin(c client.Client, args string) (api.MutatingHandler, error) {
+	return &FilePrefetcher{
+		client: c,
+		name:   Name,
+		log:    ctrl.Log.WithName("FilePrefetcher"),
+	}, nil
+}
+
+func (p *FilePrefetcher) GetName() string {
+	return p.name
+}
+
+func (p *FilePrefetcher) Mutate(pod *corev1.Pod, runtimeInfos map[string]base.RuntimeInfoInterface) (shouldStop bool, err error) {
+	if !common.CheckExpectValue(pod.Annotations, AnnotationFilePrefetcherInject, common.True) {
+		return false, nil
+	}
+
+	if common.CheckExpectValue(pod.Annotations, AnnotationFilePrefetcherInjectDone, common.True) {
+		return false, nil
+	}
+
+	config, err := p.buildFilePrefetcherConfig(pod, runtimeInfos)
+	if err != nil {
+		p.log.Error(err, "failed to build file prefetcher config")
+		err = fmt.Errorf("failed to build file prefetcher config: %v", err)
+		return true, err
+	}
+
+	if len(config.GlobPaths) == 0 {
+		p.log.Info("Skipping injecting file prefetcher sidecar container because there's no valid file-list defined in annotation", "annotation", AnnotationFilePrefetcherFileList)
+		return false, nil
+	}
+
+	containerSpec, statusFileVolume := p.buildFilePrefetcherSidecarContainer(config)
+	if config.AsyncPrefetch {
+		statusVolumeMount := corev1.VolumeMount{
+			Name:      filePrefetcherStatusVolumeName,
+			MountPath: filePrefetcherStatusVolumeMountPath,
+		}
+		for _, ctr := range pod.Spec.Containers {
+			if strings.HasPrefix(ctr.Name, common.FuseContainerName) {
+				// Skip injecting file prefetcher status volume into fluid's fuse sidecar containers.
+				continue
+			}
+			ctr.VolumeMounts = append(ctr.VolumeMounts, statusVolumeMount)
+		}
+	}
+
+	// Inject file prefetcher container right after fuse sidecar containers, we assume fluid's fuse sidecar container is injected together.
+	// e.g. before injection: [C1, FUSE1, FUSE2, C2, C3], after injection: [C1, FUSE1, FUSE2, FILEPREFETCHER, C2, C3]
+	pod.Spec.Containers = p.injectFilePrefetcherSidecar(pod.Spec.Containers, containerSpec)
+	pod.Spec.Volumes = append(pod.Spec.Volumes, statusFileVolume)
+	pod.Annotations[AnnotationFilePrefetcherInjectDone] = common.True
+
+	return false, nil
+}
+
+type filePrefetcherConfig struct {
+	// Image is the image of the file prefetcher sidecar container
+	Image string
+	// AsyncPrefetch indicates whether to use async prefetching, defaulting to false
+	AsyncPrefetch bool
+	// VolumeMountPaths is a map of volume name to mount path
+	VolumeMountPaths map[string]string
+	// GlobPaths is a string indicating all the paths to prefetch. It is a semicolon-separated list of paths
+	GlobPaths string
+	// TimeoutSeconds is a int indicating the timeout for file prefetcher, defined in seconds
+	TimeoutSeconds int
+	// ExtraEnvs is a map of extra envs to inject into the file prefetcher sidecar container
+	ExtraEnvs map[string]string
+}
+
+func (p *FilePrefetcher) buildFilePrefetcherConfig(pod *corev1.Pod, runtimeInfos map[string]base.RuntimeInfoInterface) (config filePrefetcherConfig, err error) {
+	defaultFn := func(keyValues map[string]string, key string, defaultValue string) (value string) {
+		if value, ok := keyValues[key]; ok {
+			return value
+		}
+		return defaultValue
+	}
+
+	config.Image = defaultFn(pod.Annotations, AnnotationFilePrefetcherImage, defaultFilePrefetcherImage)
+	if len(config.Image) == 0 {
+		err = fmt.Errorf("file prefetcher's image is required, set it in pod's annotation \"%s=<image>\"", AnnotationFilePrefetcherImage)
+		return
+	}
+
+	extraEnvs := map[string]string{}
+	// extraEnvsStr takes the format like: '<key1>=<value1> <key2>=<value2> <key3>=<value3>'
+	extraEnvsStr := defaultFn(pod.Annotations, AnnotationFilePrefetcherExtraEnvs, "")
+	if len(extraEnvsStr) > 0 {
+		keyValuePairs := strings.Split(extraEnvsStr, " ")
+		for _, keyValuePair := range keyValuePairs {
+			kvSlice := strings.Split(keyValuePair, "=")
+			if len(kvSlice) != 2 {
+				err = fmt.Errorf("file prefetcher's extra envs is required to be '<key1>=<value1> <key2>=<value2> <key3>=<value3>', but found unexpected key-value pair: %s", keyValuePair)
+				return
+			}
+			extraEnvs[kvSlice[0]] = kvSlice[1]
+		}
+	}
+	config.ExtraEnvs = extraEnvs
+
+	fileList := defaultFn(pod.Annotations, AnnotationFilePrefetcherFileList, filePrefetcherDefaultFileList)
+	if fileList == filePrefetcherDefaultFileList {
+		pvcNames := make([]string, 0)
+		for pvcName := range runtimeInfos {
+			pvcNames = append(pvcNames, fmt.Sprintf("pvc://%s", pvcName))
+		}
+		fileList = strings.Join(pvcNames, ";")
+	}
+	volumeMountPaths, globPaths := p.parseGlobPathsFromFileList(fileList, pod, runtimeInfos)
+	config.VolumeMountPaths = volumeMountPaths
+	config.GlobPaths = strings.Join(globPaths, ";")
+
+	asyncPrefetchStr := defaultFn(pod.Annotations, AnnotationFilePrefetcherAsync, "false")
+	if asyncPrefetch, parseErr := strconv.ParseBool(asyncPrefetchStr); parseErr != nil {
+		err = fmt.Errorf("invalid value for %s: %s, must either be false or true: %v", AnnotationFilePrefetcherAsync, asyncPrefetchStr, parseErr)
+		return
+	} else {
+		config.AsyncPrefetch = asyncPrefetch
+	}
+
+	timeoutSecondsStr := defaultFn(pod.Annotations, AnnotationFilePrefetcherTimeoutSeconds, filePrefetcherDefaultTimeoutSecondsStr)
+	if timeoutSeconds, parseErr := strconv.ParseInt(timeoutSecondsStr, 10, 32); parseErr != nil {
+		err = fmt.Errorf("invalid value for %s: %s, must be of type integer: %v", AnnotationFilePrefetcherTimeoutSeconds, timeoutSecondsStr, parseErr)
+		return
+	} else {
+		config.TimeoutSeconds = int(timeoutSeconds)
+	}
+	p.log.V(1).Info("building file prefetcher config", "config", config)
+
+	return
+}
+
+func (p *FilePrefetcher) parseGlobPathsFromFileList(fileList string, pod *corev1.Pod, runtimeInfos map[string]base.RuntimeInfoInterface) (volumeMountPaths map[string]string, globPaths []string) {
+	volumeMountPaths = map[string]string{}
+	globPaths = []string{}
+
+	if len(fileList) == 0 {
+		return
+	}
+
+	uriPaths := strings.Split(fileList, ";")
+	for _, uriPath := range uriPaths {
+		if !strings.HasPrefix(uriPath, string(common.VolumeScheme)) {
+			p.log.Info("skip adding path to prefetch list because it does not start with pvc://", "path", uriPath)
+			continue
+		}
+		// e.g. uriPath="pvc://mypvc/path/to/myfolder/*.pkl" => items=["mypvc", "path", "to", "myfolder", "*.pkl"]
+		items := strings.Split(strings.TrimPrefix(uriPath, string(common.VolumeScheme)), "/")
path: pkg/webhook/plugins/fileprefetcher/file_prefetcher.go
user: login: cheyang
body: I suggest preventing path traversal via filepath.Clean() and root path anchoring.
Like `pvc://mypvc/%2e%2e/etc/passwd ` or `pvc://mypvc/../etc/passwd `.
created_at: 2025-03-01T07:33:59Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,316 @@
+/*
+Copyright 2025 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 fileprefetcher
+
+import (
+	"fmt"
+	stdlog "log"
+	"path"
+	"strconv"
+	"strings"
+
+	"github.com/fluid-cloudnative/fluid/pkg/common"
+	"github.com/fluid-cloudnative/fluid/pkg/ddc/base"
+	"github.com/fluid-cloudnative/fluid/pkg/utils/docker"
+	"github.com/fluid-cloudnative/fluid/pkg/webhook/plugins/api"
+	"github.com/go-logr/logr"
+	corev1 "k8s.io/api/core/v1"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+const Name string = "FilePrefetcher"
+
+var defaultFilePrefetcherImage = ""
+
+func init() {
+	imageRepo := docker.GetImageRepoFromEnv(envKeyFilePrefetcherImage)
+	imageTag := docker.GetImageTagFromEnv(envKeyFilePrefetcherImage)
+	if len(imageRepo) == 0 || len(imageTag) == 0 {
+		stdlog.Printf("WARNING: env variable %s is not set, file prefetcher image is required in Pod's annotation", envKeyFilePrefetcherImage)
+		return
+	}
+	defaultFilePrefetcherImage = fmt.Sprintf("%s:%s", imageRepo, imageTag)
+	stdlog.Printf("Found %s value %s, using it as defaultFilePrefetcherImage", envKeyFilePrefetcherImage, defaultFilePrefetcherImage)
+}
+
+var _ api.MutatingHandler = &FilePrefetcher{}
+
+type FilePrefetcher struct {
+	client client.Client
+	name   string
+	log    logr.Logger
+}
+
+func NewPlugin(c client.Client, args string) (api.MutatingHandler, error) {
+	return &FilePrefetcher{
+		client: c,
+		name:   Name,
+		log:    ctrl.Log.WithName("FilePrefetcher"),
+	}, nil
+}
+
+func (p *FilePrefetcher) GetName() string {
+	return p.name
+}
+
+func (p *FilePrefetcher) Mutate(pod *corev1.Pod, runtimeInfos map[string]base.RuntimeInfoInterface) (shouldStop bool, err error) {
+	if !common.CheckExpectValue(pod.Annotations, AnnotationFilePrefetcherInject, common.True) {
+		return false, nil
+	}
+
+	if common.CheckExpectValue(pod.Annotations, AnnotationFilePrefetcherInjectDone, common.True) {
+		return false, nil
+	}
+
+	config, err := p.buildFilePrefetcherConfig(pod, runtimeInfos)
+	if err != nil {
+		p.log.Error(err, "failed to build file prefetcher config")
+		err = fmt.Errorf("failed to build file prefetcher config: %v", err)
+		return true, err
+	}
+
+	if len(config.GlobPaths) == 0 {
+		p.log.Info("Skipping injecting file prefetcher sidecar container because there's no valid file-list defined in annotation", "annotation", AnnotationFilePrefetcherFileList)
+		return false, nil
+	}
+
+	containerSpec, statusFileVolume := p.buildFilePrefetcherSidecarContainer(config)
+	if config.AsyncPrefetch {
+		statusVolumeMount := corev1.VolumeMount{
+			Name:      filePrefetcherStatusVolumeName,
+			MountPath: filePrefetcherStatusVolumeMountPath,
+		}
+		for _, ctr := range pod.Spec.Containers {
+			if strings.HasPrefix(ctr.Name, common.FuseContainerName) {
+				// Skip injecting file prefetcher status volume into fluid's fuse sidecar containers.
+				continue
+			}
+			ctr.VolumeMounts = append(ctr.VolumeMounts, statusVolumeMount)
path: pkg/webhook/plugins/fileprefetcher/file_prefetcher.go
user: login: TrafalgarZZZ
body: Done.
created_at: 2025-03-06T08:28:50Z
author_association: MEMBER

PR comments:
user: login: fluid-e2e-bot[bot]
created_at: 2025-02-10T03:30:13Z
body: Skipping CI for Draft Pull Request.
If you want CI signal for your change, please convert it to an actual PR.
You can still manually trigger a test run with `/test all`
author_association: NONE
user: login: TrafalgarZZZ
created_at: 2025-02-10T12:59:17Z
body: /test fluid-e2e
author_association: MEMBER
user: login: TrafalgarZZZ
created_at: 2025-02-11T03:11:13Z
body: /retest
author_association: MEMBER
user: login: TrafalgarZZZ
created_at: 2025-02-11T03:13:49Z
body: /retest
author_association: MEMBER
user: login: sonarqubecloud[bot]
created_at: 2025-03-06T07:35:47Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=fluid-cloudnative_fluid&pullRequest=4490) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [16 New issues](https://sonarcloud.io/project/issues?id=fluid-cloudnative_fluid&pullRequest=4490&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=fluid-cloudnative_fluid&pullRequest=4490&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [4 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=fluid-cloudnative_fluid&pullRequest=4490&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=fluid-cloudnative_fluid&pullRequest=4490&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [18.9% Duplication on New Code](https://sonarcloud.io/component_measures?id=fluid-cloudnative_fluid&pullRequest=4490&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=fluid-cloudnative_fluid&pullRequest=4490)


author_association: NONE
user: login: TrafalgarZZZ
created_at: 2025-03-06T08:29:38Z
body: /test fluid-e2e
author_association: MEMBER
user: login: fluid-e2e-bot[bot]
created_at: 2025-03-07T11:25:47Z
body: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/fluid-cloudnative/fluid/pull/4490#pullrequestreview-2666988399" title="Approved">cheyang</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=fluid-cloudnative%2Ffluid).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/fluid-cloudnative/fluid/blob/master/OWNERS)~~ [cheyang]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
author_association: NONE