diff --git a/pkg/application/inject/fuse/mount_point_script.go b/pkg/application/inject/fuse/mount_point_script.go index e0307c5849c..4c6cbb9d146 100644 --- a/pkg/application/inject/fuse/mount_point_script.go +++ b/pkg/application/inject/fuse/mount_point_script.go @@ -23,10 +23,12 @@ import ( "github.com/fluid-cloudnative/fluid/pkg/application/inject/fuse/mutator" "github.com/fluid-cloudnative/fluid/pkg/application/inject/fuse/poststart" + "github.com/fluid-cloudnative/fluid/pkg/common" "github.com/fluid-cloudnative/fluid/pkg/ddc/base" "github.com/fluid-cloudnative/fluid/pkg/utils" "github.com/fluid-cloudnative/fluid/pkg/utils/kubeclient" corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/util/retry" ) func (s *Injector) injectCheckMountReadyScript(podSpecs *mutator.MutatingPodSpecs, runtimeInfos map[string]base.RuntimeInfoInterface) error { @@ -131,24 +133,56 @@ func (s *Injector) ensureScriptConfigMapExists(namespace string) (*poststart.Scr appScriptGen := poststart.NewScriptGeneratorForApp(namespace) cm := appScriptGen.BuildConfigmap() - cmFound, err := kubeclient.IsConfigMapExist(s.client, cm.Name, cm.Namespace) + cmKey := fmt.Sprintf("%s/%s", cm.Namespace, cm.Name) + + existingCM, err := kubeclient.GetConfigmapByName(s.client, cm.Name, cm.Namespace) if err != nil { - s.log.Error(err, "error when checking configMap's existence", "cm.Name", cm.Name, "cm.Namespace", cm.Namespace) + s.log.Error(err, "error when getting configMap", "cm.Name", cm.Name, "cm.Namespace", cm.Namespace) return nil, err } - cmKey := fmt.Sprintf("%s/%s", cm.Namespace, cm.Name) - s.log.V(1).Info("after check configMap existence", "configMap", cmKey, "existence", cmFound) - if !cmFound { + if existingCM == nil { + // ConfigMap does not exist, create it + s.log.V(1).Info("configMap not found, creating", "configMap", cmKey) err = s.client.Create(context.TODO(), cm) if err != nil { if otherErr := utils.IgnoreAlreadyExists(err); otherErr != nil { s.log.Error(err, "error when creating new configMap", "cm.Name", cm.Name, "cm.Namespace", cm.Namespace) return nil, err - } else { - s.log.V(1).Info("configmap already exists, skip", "configMap", cmKey) } + s.log.V(1).Info("configmap already exists (concurrent creation), skip", "configMap", cmKey) + } + return appScriptGen, nil + } + + // ConfigMap exists, check if the script SHA256 annotation matches; update with retry on conflict. + latestSHA256 := appScriptGen.GetScriptSHA256() + if err = retry.RetryOnConflict(retry.DefaultBackoff, func() error { + current, getErr := kubeclient.GetConfigmapByName(s.client, cm.Name, cm.Namespace) + if getErr != nil { + return getErr + } + if current == nil { + // Deleted between Get calls; recreate + return s.client.Create(context.TODO(), cm) + } + if current.Annotations != nil { + if annotationSHA256, ok := current.Annotations[common.AnnotationCheckMountScriptSHA256]; ok && annotationSHA256 == latestSHA256 { + s.log.V(1).Info("configmap script is up-to-date, skip update", "configMap", cmKey) + return nil + } + } + // SHA256 mismatch or annotation missing: update the ConfigMap with latest script and SHA256 + s.log.Info("configmap script SHA256 mismatch or annotation missing, updating", "configMap", cmKey, "expectedSHA256", latestSHA256) + current.Data = cm.Data + if current.Annotations == nil { + current.Annotations = map[string]string{} } + current.Annotations[common.AnnotationCheckMountScriptSHA256] = latestSHA256 + return s.client.Update(context.TODO(), current) + }); err != nil { + s.log.Error(err, "error when ensuring configMap is up-to-date", "cm.Name", cm.Name, "cm.Namespace", cm.Namespace) + return nil, err } return appScriptGen, nil diff --git a/pkg/application/inject/fuse/mount_point_script_test.go b/pkg/application/inject/fuse/mount_point_script_test.go index 97dce9b2b17..f9a25aa9950 100644 --- a/pkg/application/inject/fuse/mount_point_script_test.go +++ b/pkg/application/inject/fuse/mount_point_script_test.go @@ -21,6 +21,7 @@ import ( "github.com/fluid-cloudnative/fluid/pkg/application/inject/fuse/mutator" "github.com/fluid-cloudnative/fluid/pkg/application/inject/fuse/poststart" + "github.com/fluid-cloudnative/fluid/pkg/common" "github.com/fluid-cloudnative/fluid/pkg/ddc/base" "github.com/go-logr/logr" . "github.com/onsi/ginkgo/v2" @@ -266,17 +267,64 @@ var _ = Describe("Fuse Injector", func() { }) }) - Context("when configmap already exists", func() { - It("should not return an error", func() { - // Create configmap first + Context("when configmap already exists with matching SHA256 annotation", func() { + It("should skip update and return without error", func() { appScriptGen := poststart.NewScriptGeneratorForApp(namespace) cm := appScriptGen.BuildConfigmap() err := fakeClient.Create(context.TODO(), cm) Expect(err).To(BeNil()) - // Try to ensure it exists again _, err = injector.ensureScriptConfigMapExists(namespace) Expect(err).To(BeNil()) + + // Verify configmap is unchanged (no extra update) + retrievedCM := &corev1.ConfigMap{} + err = fakeClient.Get(context.TODO(), client.ObjectKey{Name: cm.Name, Namespace: cm.Namespace}, retrievedCM) + Expect(err).To(BeNil()) + Expect(retrievedCM.Annotations).To(HaveKey(common.AnnotationCheckMountScriptSHA256)) + Expect(retrievedCM.Annotations[common.AnnotationCheckMountScriptSHA256]).To(Equal(appScriptGen.GetScriptSHA256())) + }) + }) + + Context("when configmap already exists with stale SHA256 annotation", func() { + It("should update the configmap with the latest script and annotation", func() { + appScriptGen := poststart.NewScriptGeneratorForApp(namespace) + cm := appScriptGen.BuildConfigmap() + // Tamper the annotation to simulate an outdated configmap + cm.Annotations[common.AnnotationCheckMountScriptSHA256] = "stale-sha256" + cm.Data["check-fluid-mount-ready.sh"] = "old script content" + err := fakeClient.Create(context.TODO(), cm) + Expect(err).To(BeNil()) + + _, err = injector.ensureScriptConfigMapExists(namespace) + Expect(err).To(BeNil()) + + // Verify configmap was updated + retrievedCM := &corev1.ConfigMap{} + err = fakeClient.Get(context.TODO(), client.ObjectKey{Name: cm.Name, Namespace: cm.Namespace}, retrievedCM) + Expect(err).To(BeNil()) + Expect(retrievedCM.Annotations[common.AnnotationCheckMountScriptSHA256]).To(Equal(appScriptGen.GetScriptSHA256())) + Expect(retrievedCM.Data["check-fluid-mount-ready.sh"]).NotTo(Equal("old script content")) + }) + }) + + Context("when configmap already exists without SHA256 annotation", func() { + It("should update the configmap to add the annotation", func() { + appScriptGen := poststart.NewScriptGeneratorForApp(namespace) + cm := appScriptGen.BuildConfigmap() + // Remove the annotation to simulate a legacy configmap + delete(cm.Annotations, common.AnnotationCheckMountScriptSHA256) + err := fakeClient.Create(context.TODO(), cm) + Expect(err).To(BeNil()) + + _, err = injector.ensureScriptConfigMapExists(namespace) + Expect(err).To(BeNil()) + + retrievedCM := &corev1.ConfigMap{} + err = fakeClient.Get(context.TODO(), client.ObjectKey{Name: cm.Name, Namespace: cm.Namespace}, retrievedCM) + Expect(err).To(BeNil()) + Expect(retrievedCM.Annotations).To(HaveKey(common.AnnotationCheckMountScriptSHA256)) + Expect(retrievedCM.Annotations[common.AnnotationCheckMountScriptSHA256]).To(Equal(appScriptGen.GetScriptSHA256())) }) }) }) diff --git a/pkg/application/inject/fuse/mutator/mutator_default.go b/pkg/application/inject/fuse/mutator/mutator_default.go index 64b8ecb1103..0c685fad2c0 100644 --- a/pkg/application/inject/fuse/mutator/mutator_default.go +++ b/pkg/application/inject/fuse/mutator/mutator_default.go @@ -22,6 +22,7 @@ import ( "encoding/hex" "fmt" "path/filepath" + "reflect" "strings" "time" @@ -29,6 +30,7 @@ import ( "github.com/pkg/errors" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/retry" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/fluid-cloudnative/fluid/pkg/application/inject/fuse/poststart" @@ -36,6 +38,8 @@ import ( "github.com/fluid-cloudnative/fluid/pkg/ddc/base" "github.com/fluid-cloudnative/fluid/pkg/utils" "github.com/fluid-cloudnative/fluid/pkg/utils/kubeclient" + + datav1alpha1 "github.com/fluid-cloudnative/fluid/api/v1alpha1" ) var ( @@ -321,20 +325,9 @@ func prepareFuseContainerPostStartScript(helper *helperData) error { // Fluid assumes pvc name is the same with runtime's name gen := poststart.NewDefaultPostStartScriptGenerator() cmKey := gen.GetNamespacedConfigMapKey(types.NamespacedName{Namespace: datasetNamespace, Name: datasetName}, template.FuseMountInfo.FsType) - found, err := kubeclient.IsConfigMapExist(helper.client, cmKey.Name, cmKey.Namespace) - if err != nil { - return err - } - if !found { - cm := gen.BuildConfigMap(dataset, cmKey) - err = helper.client.Create(context.TODO(), cm) - if err != nil { - // If ConfigMap creation succeeds concurrently, continue to mutate - if otherErr := utils.IgnoreAlreadyExists(err); otherErr != nil { - return err - } - } + if err = ensurePostStartConfigMap(helper.client, gen, dataset, cmKey); err != nil { + return err } template.FuseContainer.VolumeMounts = append(template.FuseContainer.VolumeMounts, gen.GetVolumeMount()) @@ -347,6 +340,63 @@ func prepareFuseContainerPostStartScript(helper *helperData) error { return nil } +// ensurePostStartConfigMap creates the ConfigMap if it does not exist, or updates it when the +// script content has changed (detected via SHA256 annotation). +func ensurePostStartConfigMap(c client.Client, gen poststart.ScriptGenerator, dataset *datav1alpha1.Dataset, cmKey types.NamespacedName) error { + existingCM, err := kubeclient.GetConfigmapByName(c, cmKey.Name, cmKey.Namespace) + if err != nil { + return err + } + + if existingCM == nil { + cm := gen.BuildConfigMap(dataset, cmKey) + if createErr := c.Create(context.TODO(), cm); createErr != nil { + // If ConfigMap creation succeeds concurrently, continue to mutate + return utils.IgnoreAlreadyExists(createErr) + } + return nil + } + + // ConfigMap exists; update with retry on conflict to handle concurrent webhook mutations. + return retry.RetryOnConflict(retry.DefaultBackoff, func() error { + return updateConfigMapIfStale(c, gen, dataset, cmKey) + }) +} + +// updateConfigMapIfStale fetches the current ConfigMap from the cluster and updates it when the +// SHA256 annotation does not match the latest script. It is designed to be called inside a +// RetryOnConflict loop. +func updateConfigMapIfStale(c client.Client, gen poststart.ScriptGenerator, dataset *datav1alpha1.Dataset, cmKey types.NamespacedName) error { + current, err := kubeclient.GetConfigmapByName(c, cmKey.Name, cmKey.Namespace) + if err != nil { + return err + } + if current == nil { + // Deleted between Get calls; recreate + return c.Create(context.TODO(), gen.BuildConfigMap(dataset, cmKey)) + } + + if isConfigMapUpToDate(current, gen.GetScriptSHA256()) { + return nil + } + + latest := gen.RefreshConfigMapContents(dataset, cmKey, current.DeepCopy()) + if reflect.DeepEqual(current, latest) { + return nil + } + return c.Update(context.TODO(), latest) +} + +// isConfigMapUpToDate returns true when the annotations already carry the expected SHA256, +// meaning no update is needed. +func isConfigMapUpToDate(cm *corev1.ConfigMap, expectedSHA256 string) bool { + if cm == nil || cm.Annotations == nil { + return false + } + sha, ok := cm.Annotations[common.AnnotationCheckMountScriptSHA256] + return ok && sha == expectedSHA256 +} + func transformTemplateWithCacheDirDisabled(helper *helperData) { template := helper.template template.FuseContainer.VolumeMounts = utils.TrimVolumeMounts(template.FuseContainer.VolumeMounts, cacheDirNames) diff --git a/pkg/application/inject/fuse/mutator/mutator_default_test.go b/pkg/application/inject/fuse/mutator/mutator_default_test.go index 21c43032548..ad0c4c23d31 100644 --- a/pkg/application/inject/fuse/mutator/mutator_default_test.go +++ b/pkg/application/inject/fuse/mutator/mutator_default_test.go @@ -1,6 +1,7 @@ package mutator import ( + "context" "fmt" datav1alpha1 "github.com/fluid-cloudnative/fluid/api/v1alpha1" @@ -619,3 +620,126 @@ var _ = Describe("Default mutator related unit tests", Label("pkg.application.in }) }) }) + +var _ = Describe("prepareFuseContainerPostStartScript ConfigMap update logic", Label("pkg.application.inject.fuse.mutator.mutator_default_test.go"), func() { + var ( + testScheme *runtime.Scheme + datasetName string + datasetNamespace string + dataset *datav1alpha1.Dataset + thinRuntime *datav1alpha1.ThinRuntime + daemonSet *appsv1.DaemonSet + pv *corev1.PersistentVolume + podToMutate *corev1.Pod + ) + + BeforeEach(func() { + testScheme = runtime.NewScheme() + _ = datav1alpha1.AddToScheme(testScheme) + _ = corev1.AddToScheme(testScheme) + _ = appsv1.AddToScheme(testScheme) + + datasetName = "test-dataset" + datasetNamespace = "fluid" + dataset, thinRuntime, daemonSet, pv = test_buildFluidResources(datasetName, datasetNamespace) + podToMutate = test_buildPodToMutate([]string{datasetName}) + }) + + When("the configmap already exists with a matching SHA256 annotation", func() { + It("should skip the update and not return an error", func() { + c := fake.NewFakeClientWithScheme(testScheme, dataset, thinRuntime, daemonSet, pv) + pod, err := applicationspod.NewApplication(podToMutate).GetPodSpecs() + Expect(err).ShouldNot(HaveOccurred()) + specs, err := CollectFluidObjectSpecs(pod[0]) + Expect(err).NotTo(HaveOccurred()) + + args := MutatorBuildArgs{ + Client: c, + Log: fake.NullLogger(), + Options: common.FuseSidecarInjectOption{ + EnableCacheDir: false, + SkipSidecarPostStartInject: false, + }, + Specs: specs, + } + // First mutate: creates the ConfigMap + mut := NewDefaultMutator(args) + runtimeInfo, err := base.GetRuntimeInfo(c, datasetName, datasetNamespace) + Expect(err).NotTo(HaveOccurred()) + err = mut.MutateWithRuntimeInfo(datasetName, runtimeInfo, "-0") + Expect(err).To(BeNil()) + + // Re-build fresh args/mutator to simulate a second webhook call + pod2, _ := applicationspod.NewApplication(podToMutate).GetPodSpecs() + specs2, _ := CollectFluidObjectSpecs(pod2[0]) + args2 := MutatorBuildArgs{Client: c, Log: fake.NullLogger(), Options: args.Options, Specs: specs2} + mut2 := NewDefaultMutator(args2) + runtimeInfo2, err := base.GetRuntimeInfo(c, datasetName, datasetNamespace) + Expect(err).NotTo(HaveOccurred()) + + // Second mutate: SHA256 matches, should not error + err = mut2.MutateWithRuntimeInfo(datasetName, runtimeInfo2, "-0") + Expect(err).To(BeNil()) + }) + }) + + When("the configmap already exists with a stale SHA256 annotation", func() { + It("should refresh the configmap Data and annotation", func() { + c := fake.NewFakeClientWithScheme(testScheme, dataset, thinRuntime, daemonSet, pv) + pod, err := applicationspod.NewApplication(podToMutate).GetPodSpecs() + Expect(err).ShouldNot(HaveOccurred()) + specs, err := CollectFluidObjectSpecs(pod[0]) + Expect(err).NotTo(HaveOccurred()) + + args := MutatorBuildArgs{ + Client: c, + Log: fake.NullLogger(), + Options: common.FuseSidecarInjectOption{ + EnableCacheDir: false, + SkipSidecarPostStartInject: false, + }, + Specs: specs, + } + // First mutate: creates the ConfigMap + mut := NewDefaultMutator(args) + runtimeInfo, err := base.GetRuntimeInfo(c, datasetName, datasetNamespace) + Expect(err).NotTo(HaveOccurred()) + err = mut.MutateWithRuntimeInfo(datasetName, runtimeInfo, "-0") + Expect(err).To(BeNil()) + + // Deliberately corrupt the SHA256 annotation to simulate a stale configmap + cmList := &corev1.ConfigMapList{} + Expect(c.List(context.TODO(), cmList)).To(Succeed()) + for i := range cmList.Items { + cm := &cmList.Items[i] + if cm.Annotations != nil { + if _, ok := cm.Annotations[common.AnnotationCheckMountScriptSHA256]; ok { + cm.Annotations[common.AnnotationCheckMountScriptSHA256] = "deliberately-stale-sha" + Expect(c.Update(context.TODO(), cm)).To(Succeed()) + } + } + } + + // Second mutate: SHA256 mismatch → should trigger update + pod2, _ := applicationspod.NewApplication(podToMutate).GetPodSpecs() + specs2, _ := CollectFluidObjectSpecs(pod2[0]) + args2 := MutatorBuildArgs{Client: c, Log: fake.NullLogger(), Options: args.Options, Specs: specs2} + mut2 := NewDefaultMutator(args2) + runtimeInfo2, err := base.GetRuntimeInfo(c, datasetName, datasetNamespace) + Expect(err).NotTo(HaveOccurred()) + err = mut2.MutateWithRuntimeInfo(datasetName, runtimeInfo2, "-0") + Expect(err).To(BeNil()) + + // Verify the SHA256 annotation was refreshed + updatedCmList := &corev1.ConfigMapList{} + Expect(c.List(context.TODO(), updatedCmList)).To(Succeed()) + for _, cm := range updatedCmList.Items { + if cm.Annotations != nil { + if sha, ok := cm.Annotations[common.AnnotationCheckMountScriptSHA256]; ok { + Expect(sha).NotTo(Equal("deliberately-stale-sha")) + } + } + } + }) + }) +}) diff --git a/pkg/application/inject/fuse/poststart/check_fuse_app.go b/pkg/application/inject/fuse/poststart/check_fuse_app.go index b12d67a0c05..a1505844032 100644 --- a/pkg/application/inject/fuse/poststart/check_fuse_app.go +++ b/pkg/application/inject/fuse/poststart/check_fuse_app.go @@ -22,6 +22,8 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" + + "github.com/fluid-cloudnative/fluid/pkg/common" ) const ( @@ -31,6 +33,15 @@ const ( appConfigMapName = appVolName ) +// appScriptContentSHA256 stores the SHA256 hex of the app script content (first 63 chars for K8s label compatibility), +// computed once at package initialization. +var appScriptContentSHA256 string + +func init() { + // K8s label values must be <= 63 characters; SHA256 hex is 64 chars, so truncate to 63. + appScriptContentSHA256 = computeScriptSHA256(replacer.Replace(contentCheckMountReadyScript)) +} + // The standard error returned from the execution of the postStartHook // will appear in the kubelet logs to clarify the reason for the PostStartHook. var contentCheckMountReadyScript = `#!/bin/bash @@ -114,11 +125,19 @@ func (a *ScriptGeneratorForApp) BuildConfigmap() *corev1.ConfigMap { ObjectMeta: metav1.ObjectMeta{ Name: a.getConfigmapName(), Namespace: a.namespace, + Annotations: map[string]string{ + common.AnnotationCheckMountScriptSHA256: appScriptContentSHA256, + }, }, Data: data, } } +// GetScriptSHA256 returns the SHA256 of the app script content computed at package init. +func (a *ScriptGeneratorForApp) GetScriptSHA256() string { + return appScriptContentSHA256 +} + func (a *ScriptGeneratorForApp) getConfigmapName() string { return appConfigMapName } diff --git a/pkg/application/inject/fuse/poststart/check_fuse_default.go b/pkg/application/inject/fuse/poststart/check_fuse_default.go index 7dd13112423..8e222632f09 100644 --- a/pkg/application/inject/fuse/poststart/check_fuse_default.go +++ b/pkg/application/inject/fuse/poststart/check_fuse_default.go @@ -102,6 +102,14 @@ log "succeed in checking mount point $ConditionPathIsMountPoint after $count att ` ) +// defaultPrivilegedSidecarScriptSHA256 stores the SHA256 (first 63 chars) of the privileged +// sidecar script content, computed once at package initialization. +var defaultPrivilegedSidecarScriptSHA256 string + +func init() { + defaultPrivilegedSidecarScriptSHA256 = computeScriptSHA256(replacer.Replace(contentPrivilegedSidecar)) +} + // DefaultMountCheckScriptGenerator is a generator to render resources and specs related to post start mount-check script for the DefaultMutator type defaultPostStartScriptGenerator struct { scriptGeneratorHelper @@ -114,6 +122,7 @@ func NewDefaultPostStartScriptGenerator() *defaultPostStartScriptGenerator { scriptFileName: "check-mount.sh", scriptMountPath: "/check-mount.sh", scriptContent: replacer.Replace(contentPrivilegedSidecar), + scriptSHA256: defaultPrivilegedSidecarScriptSHA256, }, } } diff --git a/pkg/application/inject/fuse/poststart/script_gen_helper.go b/pkg/application/inject/fuse/poststart/script_gen_helper.go index 9400e974cc0..3159e614eb3 100644 --- a/pkg/application/inject/fuse/poststart/script_gen_helper.go +++ b/pkg/application/inject/fuse/poststart/script_gen_helper.go @@ -17,6 +17,8 @@ limitations under the License. package poststart import ( + "crypto/sha256" + "fmt" "strings" corev1 "k8s.io/api/core/v1" @@ -34,6 +36,26 @@ type scriptGeneratorHelper struct { scriptContent string scriptFileName string scriptMountPath string + scriptSHA256 string // first 63 chars of SHA256 of the scriptContent +} + +// ScriptGenerator is the interface that concrete post-start script generators must implement. +// It is used by mutator helpers to create and refresh the check-mount ConfigMap. +type ScriptGenerator interface { + BuildConfigMap(dataset *datav1alpha1.Dataset, configMapKey types.NamespacedName) *corev1.ConfigMap + RefreshConfigMapContents(dataset *datav1alpha1.Dataset, configMapKey types.NamespacedName, existingCM *corev1.ConfigMap) *corev1.ConfigMap + GetScriptSHA256() string + GetNamespacedConfigMapKey(datasetKey types.NamespacedName, runtimeType string) types.NamespacedName + GetVolume(configMapKey types.NamespacedName) corev1.Volume + GetVolumeMount() corev1.VolumeMount + GetPostStartCommand(mountPath, mountType, subPath string) *corev1.LifecycleHandler +} + +// computeScriptSHA256 computes the SHA256 of content and returns the first 63 hex chars +// (K8s label values must be <= 63 characters; SHA256 hex is 64 chars). +func computeScriptSHA256(content string) string { + sum := sha256.Sum256([]byte(content)) + return fmt.Sprintf("%x", sum)[:63] } func (helper *scriptGeneratorHelper) BuildConfigMap(dataset *datav1alpha1.Dataset, configMapKey types.NamespacedName) *corev1.ConfigMap { @@ -47,11 +69,47 @@ func (helper *scriptGeneratorHelper) BuildConfigMap(dataset *datav1alpha1.Datase Labels: map[string]string{ common.LabelAnnotationDatasetId: utils.GetDatasetId(configMapKey.Namespace, dataset.Name, string(dataset.UID)), }, + Annotations: map[string]string{ + common.AnnotationCheckMountScriptSHA256: helper.scriptSHA256, + }, }, Data: data, } } +// GetScriptSHA256 returns the SHA256 of the helper's script content. +// If the SHA was not set at construction time, it is computed lazily from scriptContent. +func (helper *scriptGeneratorHelper) GetScriptSHA256() string { + if helper.scriptSHA256 != "" { + return helper.scriptSHA256 + } + if helper.scriptContent == "" { + return "" + } + return computeScriptSHA256(helper.scriptContent) +} + +// RefreshConfigMapContents updates existingCM's Data, Labels, and Annotations in-place to +// match what BuildConfigMap would produce for the given dataset and configMapKey, then returns +// the updated object. The caller is responsible for persisting the change. +func (helper *scriptGeneratorHelper) RefreshConfigMapContents(dataset *datav1alpha1.Dataset, configMapKey types.NamespacedName, existingCM *corev1.ConfigMap) *corev1.ConfigMap { + newCM := helper.BuildConfigMap(dataset, configMapKey) + existingCM.Data = newCM.Data + if existingCM.Labels == nil { + existingCM.Labels = map[string]string{} + } + for k, v := range newCM.Labels { + existingCM.Labels[k] = v + } + if existingCM.Annotations == nil { + existingCM.Annotations = map[string]string{} + } + for k, v := range newCM.Annotations { + existingCM.Annotations[k] = v + } + return existingCM +} + func (helper *scriptGeneratorHelper) GetNamespacedConfigMapKey(datasetKey types.NamespacedName, runtimeType string) types.NamespacedName { return types.NamespacedName{ Namespace: datasetKey.Namespace, diff --git a/pkg/application/inject/fuse/poststart/script_gen_helper_test.go b/pkg/application/inject/fuse/poststart/script_gen_helper_test.go index 3d8c27b4b69..70cb7afd768 100644 --- a/pkg/application/inject/fuse/poststart/script_gen_helper_test.go +++ b/pkg/application/inject/fuse/poststart/script_gen_helper_test.go @@ -17,6 +17,8 @@ limitations under the License. package poststart import ( + "fmt" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -54,6 +56,7 @@ var _ = Describe("ScriptGeneratorHelper", func() { Expect(cm.Namespace).To(Equal("default")) Expect(cm.Data).To(HaveKeyWithValue("init.sh", "#!/bin/bash\necho 'test'")) Expect(cm.Labels).To(HaveKeyWithValue(common.LabelAnnotationDatasetId, "default-test-dataset")) + Expect(cm.Annotations).To(HaveKey(common.AnnotationCheckMountScriptSHA256)) }) }) @@ -281,3 +284,261 @@ var _ = Describe("ScriptGeneratorHelper", func() { }) }) }) + +var _ = Describe("ScriptGeneratorForApp", func() { + Describe("NewScriptGeneratorForApp", func() { + It("should create generator with correct namespace", func() { + g := NewScriptGeneratorForApp("test-ns") + Expect(g).NotTo(BeNil()) + Expect(g.namespace).To(Equal("test-ns")) + }) + }) + + Describe("BuildConfigmap", func() { + It("should create configmap with correct name, namespace and script content", func() { + g := NewScriptGeneratorForApp("default") + cm := g.BuildConfigmap() + + Expect(cm.Name).To(Equal(appConfigMapName)) + Expect(cm.Namespace).To(Equal("default")) + Expect(cm.Data).To(HaveKey(appScriptName)) + Expect(cm.Data[appScriptName]).NotTo(BeEmpty()) + }) + + It("should set the LabelCheckMountScriptSHA256 label", func() { + g := NewScriptGeneratorForApp("default") + cm := g.BuildConfigmap() + + Expect(cm.Annotations).To(HaveKey(common.AnnotationCheckMountScriptSHA256)) + Expect(cm.Annotations[common.AnnotationCheckMountScriptSHA256]).To(Equal(appScriptContentSHA256)) + }) + + It("SHA256 label value should be at most 63 characters", func() { + g := NewScriptGeneratorForApp("default") + cm := g.BuildConfigmap() + + sha256Annotation := cm.Annotations[common.AnnotationCheckMountScriptSHA256] + Expect(len(sha256Annotation)).To(BeNumerically("<=", 63)) + }) + + It("should produce the same configmap for different namespaces with only namespace differing", func() { + g1 := NewScriptGeneratorForApp("ns-a") + g2 := NewScriptGeneratorForApp("ns-b") + cm1 := g1.BuildConfigmap() + cm2 := g2.BuildConfigmap() + + Expect(cm1.Name).To(Equal(cm2.Name)) + Expect(cm1.Namespace).To(Equal("ns-a")) + Expect(cm2.Namespace).To(Equal("ns-b")) + Expect(cm1.Data).To(Equal(cm2.Data)) + Expect(cm1.Labels).To(Equal(cm2.Labels)) + }) + }) + + Describe("GetScriptSHA256", func() { + It("should return the same SHA256 as stored in appScriptContentSHA256", func() { + g := NewScriptGeneratorForApp("default") + Expect(g.GetScriptSHA256()).To(Equal(appScriptContentSHA256)) + }) + + It("should return a non-empty SHA256 string with length <= 63", func() { + g := NewScriptGeneratorForApp("default") + sha := g.GetScriptSHA256() + Expect(sha).NotTo(BeEmpty()) + Expect(len(sha)).To(BeNumerically("<=", 63)) + }) + }) + + Describe("GetPostStartCommand", func() { + It("should return correct exec command with given paths and types", func() { + g := NewScriptGeneratorForApp("default") + handler := g.GetPostStartCommand("/data1:/data2", "alluxio:jindo") + + Expect(handler).NotTo(BeNil()) + Expect(handler.Exec).NotTo(BeNil()) + expectedCmd := fmt.Sprintf("time %s %s %s", appScriptPath, "/data1:/data2", "alluxio:jindo") + Expect(handler.Exec.Command).To(Equal([]string{"bash", "-c", expectedCmd})) + }) + + It("should include the script path in the command", func() { + g := NewScriptGeneratorForApp("default") + handler := g.GetPostStartCommand("/mnt/data", "juicefs") + + Expect(handler.Exec.Command[2]).To(ContainSubstring(appScriptPath)) + }) + }) + + Describe("GetVolume", func() { + It("should return volume with correct name and configmap reference", func() { + g := NewScriptGeneratorForApp("default") + vol := g.GetVolume() + + Expect(vol.Name).To(Equal(appVolName)) + Expect(vol.VolumeSource.ConfigMap).NotTo(BeNil()) + Expect(vol.VolumeSource.ConfigMap.Name).To(Equal(appConfigMapName)) + }) + + It("should set default mode to 0755", func() { + g := NewScriptGeneratorForApp("default") + vol := g.GetVolume() + + Expect(vol.VolumeSource.ConfigMap.DefaultMode).NotTo(BeNil()) + Expect(*vol.VolumeSource.ConfigMap.DefaultMode).To(Equal(int32(0755))) + }) + }) + + Describe("GetVolumeMount", func() { + It("should return volume mount with correct properties", func() { + g := NewScriptGeneratorForApp("default") + vm := g.GetVolumeMount() + + Expect(vm.Name).To(Equal(appVolName)) + Expect(vm.MountPath).To(Equal(appScriptPath)) + Expect(vm.SubPath).To(Equal(appScriptName)) + Expect(vm.ReadOnly).To(BeTrue()) + }) + }) + + Describe("appScriptContentSHA256 init", func() { + It("should be initialized with a non-empty value at package init", func() { + Expect(appScriptContentSHA256).NotTo(BeEmpty()) + }) + + It("should be consistent with computeScriptSHA256 of the replaced script content", func() { + expected := computeScriptSHA256(replacer.Replace(contentCheckMountReadyScript)) + Expect(appScriptContentSHA256).To(Equal(expected)) + }) + + It("should match the dataset-level LabelAnnotationDatasetId label format", func() { + // Verify label value length constraint: SHA256 hex is 64 chars, truncated to 63 + Expect(len(appScriptContentSHA256)).To(Equal(63)) + }) + }) + + Describe("BuildConfigmap label consistency with GetScriptSHA256", func() { + It("should have consistent SHA256 between label and GetScriptSHA256", func() { + g := NewScriptGeneratorForApp("test-ns") + cm := g.BuildConfigmap() + sha := g.GetScriptSHA256() + + Expect(cm.Annotations[common.AnnotationCheckMountScriptSHA256]).To(Equal(sha)) + }) + }) + + Describe("integration: BuildConfigmap with dataset-style label check", func() { + It("should set label from a dataset-independent fixed hash", func() { + // The app configmap SHA256 is fixed (not dataset-scoped), unlike dataset-level configmaps + _ = &datav1alpha1.Dataset{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sample-dataset", + Namespace: "default", + UID: "sample-uid", + }, + } + g := NewScriptGeneratorForApp("default") + cm := g.BuildConfigmap() + + // App configmap does NOT have LabelAnnotationDatasetId (dataset-independent) + Expect(cm.Labels).NotTo(HaveKey(common.LabelAnnotationDatasetId)) + // But it MUST have the SHA256 annotation + Expect(cm.Annotations).To(HaveKey(common.AnnotationCheckMountScriptSHA256)) + }) + }) +}) + +var _ = Describe("scriptGeneratorHelper.RefreshConfigMapContents", func() { + var ( + dataset *datav1alpha1.Dataset + configMapKey types.NamespacedName + helper *scriptGeneratorHelper + ) + + BeforeEach(func() { + dataset = &datav1alpha1.Dataset{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-dataset", + Namespace: "default", + UID: "test-uid", + }, + } + configMapKey = types.NamespacedName{Name: "test-cm", Namespace: "default"} + helper = &scriptGeneratorHelper{ + configMapName: "test-config", + scriptFileName: "check-mount.sh", + scriptContent: "#!/bin/bash\necho hello", + scriptSHA256: computeScriptSHA256("#!/bin/bash\necho hello"), + } + }) + + It("should overwrite Data with the new script content", func() { + existing := helper.BuildConfigMap(dataset, configMapKey) + existing.Data[helper.scriptFileName] = "old content" + + helper.RefreshConfigMapContents(dataset, configMapKey, existing) + + Expect(existing.Data[helper.scriptFileName]).To(Equal("#!/bin/bash\necho hello")) + }) + + It("should set the SHA256 annotation on the existing configmap", func() { + existing := helper.BuildConfigMap(dataset, configMapKey) + delete(existing.Annotations, common.AnnotationCheckMountScriptSHA256) + + helper.RefreshConfigMapContents(dataset, configMapKey, existing) + + Expect(existing.Annotations).To(HaveKeyWithValue(common.AnnotationCheckMountScriptSHA256, helper.scriptSHA256)) + }) + + It("should overwrite a stale SHA256 annotation", func() { + existing := helper.BuildConfigMap(dataset, configMapKey) + existing.Annotations[common.AnnotationCheckMountScriptSHA256] = "stale-sha" + + helper.RefreshConfigMapContents(dataset, configMapKey, existing) + + Expect(existing.Annotations[common.AnnotationCheckMountScriptSHA256]).To(Equal(helper.scriptSHA256)) + }) + + It("should preserve extra labels not managed by the generator", func() { + existing := helper.BuildConfigMap(dataset, configMapKey) + existing.Labels["user-defined-label"] = "preserved" + + helper.RefreshConfigMapContents(dataset, configMapKey, existing) + + Expect(existing.Labels).To(HaveKeyWithValue("user-defined-label", "preserved")) + }) + + It("should preserve extra annotations not managed by the generator", func() { + existing := helper.BuildConfigMap(dataset, configMapKey) + existing.Annotations["kubectl.kubernetes.io/last-applied-configuration"] = "some-value" + + helper.RefreshConfigMapContents(dataset, configMapKey, existing) + + Expect(existing.Annotations).To(HaveKey("kubectl.kubernetes.io/last-applied-configuration")) + }) + + It("should initialize nil Labels before merging", func() { + existing := helper.BuildConfigMap(dataset, configMapKey) + existing.Labels = nil + + helper.RefreshConfigMapContents(dataset, configMapKey, existing) + + Expect(existing.Labels).NotTo(BeNil()) + Expect(existing.Labels).To(HaveKey(common.LabelAnnotationDatasetId)) + }) + + It("should initialize nil Annotations before merging", func() { + existing := helper.BuildConfigMap(dataset, configMapKey) + existing.Annotations = nil + + helper.RefreshConfigMapContents(dataset, configMapKey, existing) + + Expect(existing.Annotations).NotTo(BeNil()) + Expect(existing.Annotations).To(HaveKey(common.AnnotationCheckMountScriptSHA256)) + }) + + It("should return the same object pointer", func() { + existing := helper.BuildConfigMap(dataset, configMapKey) + result := helper.RefreshConfigMapContents(dataset, configMapKey, existing) + + Expect(result).To(BeIdenticalTo(existing)) + }) +}) diff --git a/pkg/common/label.go b/pkg/common/label.go index f806d9489b4..f735ed92651 100644 --- a/pkg/common/label.go +++ b/pkg/common/label.go @@ -77,6 +77,11 @@ const ( // "Sidecar": for only sidecar to skip check mount ready, AnnotationSkipCheckMountReadyTarget = LabelAnnotationPrefix + "skip-check-mount-ready-target" + // AnnotationCheckMountScriptSHA256 is an annotation key on the check-mount ConfigMap that stores + // the SHA256 (first 63 chars) of the script content, used to detect script updates. + // i.e. fluid.io/check-mount-script-sha256 + AnnotationCheckMountScriptSHA256 = LabelAnnotationPrefix + "check-mount-script-sha256" + // AnnotationDisableRuntimeHelmValueConfig is a runtime label indicates the configmap contains helm value will not be created in setup. AnnotationDisableRuntimeHelmValueConfig = "runtime." + LabelAnnotationPrefix + "disable-helm-value-config"