Skip to content

Commit ad9796c

Browse files
committed
fix(thin): reconcile post-ready ThinRuntime fuse template updates
ThinEngine.SyncRuntime was an empty stub, while pkg/ddc/base/syncs.go calls it on every reconciliation. The fuse values were therefore only ever rendered once, during setup, so editing a ready ThinRuntime's spec.fuse never reached the rendered values ConfigMap or the fuse DaemonSet and operators had to patch the generated DaemonSet by hand. Implement it following the JuiceFS shape, treating the helm values ConfigMap as the last synced state: re-render the desired value from the ThinRuntime and its ThinRuntimeProfile, diff it against that last synced state, push the differences into the fuse DaemonSet, and only then commit the advanced value back to the ConfigMap, so an interrupted sync is retried instead of forgotten. Covered fields are resources, image, imageTag, imagePullPolicy, envs (which is also how fuse options reach the pod), lifecycle, pod labels and annotations, volumes and volumeMounts. nodeSelector is left alone because transformFuse injects the fuse scheduling label CSI relies on, and configValue is already reconciled by updateFuseConfigOnChange. The fuse DaemonSet uses the OnDelete update strategy, so the template is updated without restarting running fuse pods. The strategy is verified before every sync, and the change is surfaced as a FuseTemplateUpdated event that spells out the rollout semantics. parseFuseOptions now sorts the rendered mount options. Its map iteration order was previously unobservable, but SyncRuntime would otherwise see a different mount options env variable on every reconciliation and keep updating the DaemonSet forever. Also add utils.TransformInternalResourcesToCoreV1Resources, the missing inverse of TransformCoreV1ResourcesToInternalResources, needed to compare a value that round tripped through the values ConfigMap against the live DaemonSet. Fixes #6150 Signed-off-by: cheyang <cheyang.cy@alibaba-inc.com>
1 parent 05f0665 commit ad9796c

7 files changed

Lines changed: 1061 additions & 14 deletions

File tree

pkg/common/constants.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ const (
4040

4141
FuseUmountDuplicate = "UnmountDuplicateMountpoint"
4242

43+
FuseTemplateUpdated = "FuseTemplateUpdated"
44+
4345
RuntimeDeprecated = "RuntimeDeprecated"
4446

4547
RuntimeWithSecretNotSupported = "RuntimeWithSecretNotSupported"

pkg/ddc/thin/sync_runtime.go

Lines changed: 305 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,311 @@
1616

1717
package thin
1818

19-
import cruntime "github.com/fluid-cloudnative/fluid/pkg/runtime"
19+
import (
20+
"context"
21+
"reflect"
22+
23+
appsv1 "k8s.io/api/apps/v1"
24+
corev1 "k8s.io/api/core/v1"
25+
apierrs "k8s.io/apimachinery/pkg/api/errors"
26+
"k8s.io/apimachinery/pkg/types"
27+
"k8s.io/client-go/util/retry"
28+
29+
datav1alpha1 "github.com/fluid-cloudnative/fluid/api/v1alpha1"
30+
"github.com/fluid-cloudnative/fluid/pkg/common"
31+
cruntime "github.com/fluid-cloudnative/fluid/pkg/runtime"
32+
"github.com/fluid-cloudnative/fluid/pkg/utils"
33+
"github.com/fluid-cloudnative/fluid/pkg/utils/kubeclient"
34+
runtimeOpts "github.com/fluid-cloudnative/fluid/pkg/utils/runtimes/options"
35+
)
36+
37+
// fuseContainerName is the name of the fuse container rendered by the thin chart,
38+
// see charts/thin/templates/fuse/daemonset.yaml.
39+
const fuseContainerName = "thin-fuse"
40+
41+
// SyncRuntime reconciles the fuse template fields of a ThinRuntime that has already been set up.
42+
// Without it, editing a ready ThinRuntime's spec.fuse never reaches the rendered values or the fuse
43+
// DaemonSet, because they are only ever generated once during setup.
44+
//
45+
// The helm values ConfigMap is treated as the last synced state: the desired state is re-rendered
46+
// from the ThinRuntime and its ThinRuntimeProfile, diffed against that last synced state, pushed to
47+
// the DaemonSet, and only then committed back to the ConfigMap. Doing it in that order means an
48+
// interrupted sync is retried on the next reconciliation instead of being silently forgotten.
49+
//
50+
// The fuse DaemonSet uses the OnDelete update strategy, so updating its template does not restart
51+
// running fuse pods. They pick up the new template whenever they are deleted, which is why the
52+
// change is also surfaced as an event.
53+
func (t *ThinEngine) SyncRuntime(ctx cruntime.ReconcileRequestContext) (changed bool, err error) {
54+
if runtimeOpts.ShouldSkipSyncingRuntime() {
55+
t.Log.V(1).Info("Skipping runtime sync due to CONTROLLER_SKIP_SYNCING_RUNTIME being enabled")
56+
return
57+
}
58+
59+
runtime, err := t.getRuntime()
60+
if err != nil {
61+
return
62+
}
63+
64+
// The engine is cached across reconciliations, so t.runtime and t.runtimeProfile may be stale.
65+
profile, err := utils.GetThinRuntimeProfile(t.Client, runtime.Spec.ThinRuntimeProfileName)
66+
if err != nil {
67+
if apierrs.IsNotFound(err) {
68+
t.Log.Info("ThinRuntimeProfile not found, skip syncing the runtime spec",
69+
"profile", runtime.Spec.ThinRuntimeProfileName)
70+
return false, nil
71+
}
72+
return false, err
73+
}
74+
75+
latestValue, err := t.transform(runtime, profile)
76+
if err != nil {
77+
return
78+
}
79+
80+
err = retry.RetryOnConflict(retry.DefaultBackoff, func() error {
81+
valueToSync, innerErr := t.GetValueFromConfigmap()
82+
if innerErr != nil {
83+
return innerErr
84+
}
85+
if valueToSync == nil {
86+
// The user opted out of the values ConfigMap, so there is no last synced state to diff
87+
// against. Degrade to not syncing rather than failing the whole reconciliation.
88+
t.Log.Info("Helm value configmap not found, skip syncing the runtime spec",
89+
"configmap", t.getHelmValuesConfigMapName())
90+
return nil
91+
}
92+
93+
fuseChanged, innerErr := t.syncFuseSpec(valueToSync, latestValue)
94+
if innerErr != nil {
95+
return innerErr
96+
}
97+
98+
changed = fuseChanged
99+
if !changed {
100+
return nil
101+
}
102+
103+
t.Log.Info("Committing the changed value to the helm value configmap", "name", t.name, "namespace", t.namespace)
104+
if innerErr = t.SaveValueToConfigmap(valueToSync); innerErr != nil {
105+
t.Log.Error(innerErr, "failed to save the changed value to the helm value configmap")
106+
return innerErr
107+
}
108+
109+
return nil
110+
})
111+
112+
if err != nil {
113+
t.Log.Error(err, "Failed to sync the runtime spec")
114+
return false, err
115+
}
116+
117+
if changed {
118+
t.recordFuseTemplateUpdated(runtime)
119+
}
20120

21-
func (t ThinEngine) SyncRuntime(ctx cruntime.ReconcileRequestContext) (changed bool, err error) {
22121
return
23122
}
123+
124+
func (t *ThinEngine) recordFuseTemplateUpdated(runtime *datav1alpha1.ThinRuntime) {
125+
if t.Recorder == nil {
126+
return
127+
}
128+
t.Recorder.Eventf(runtime, corev1.EventTypeNormal, common.FuseTemplateUpdated,
129+
"Updated the template of fuse daemonset %s. Its update strategy is OnDelete, so running fuse pods keep the previous template until they are deleted.",
130+
t.getFuseName())
131+
}
132+
133+
// syncFuseSpec pushes the differences between the last synced value and the latest value into the
134+
// fuse DaemonSet. oldValue is advanced in place for every field that was actually pushed, so that
135+
// the caller can commit it as the new last synced state.
136+
func (t *ThinEngine) syncFuseSpec(oldValue, latestValue *ThinValue) (changed bool, err error) {
137+
t.Log.V(1).Info("entering syncFuseSpec")
138+
defer func() {
139+
t.Log.V(1).Info("exiting syncFuseSpec")
140+
}()
141+
142+
fuses, err := kubeclient.GetDaemonset(t.Client, t.getFuseName(), t.namespace)
143+
if err != nil {
144+
return false, err
145+
}
146+
147+
if fuses.Spec.UpdateStrategy.Type != appsv1.OnDeleteDaemonSetStrategyType {
148+
// Updating the template of a RollingUpdate daemonset would restart the running fuse pods and
149+
// break the applications mounting them, so switch the strategy first and let the resulting
150+
// update event trigger a new reconciliation.
151+
t.Log.V(1).Info("Fuse daemonset's update strategy is not safe to sync fuse spec",
152+
"updateStrategy", fuses.Spec.UpdateStrategy.Type)
153+
if err = kubeclient.UpdateDaemonSetUpdateStrategy(t.Client, fuses.Name, fuses.Namespace,
154+
appsv1.DaemonSetUpdateStrategy{Type: appsv1.OnDeleteDaemonSetStrategyType}); err != nil {
155+
return false, err
156+
}
157+
t.Log.Info("syncFuseSpec: successfully updated fuse daemonset's update strategy to OnDelete",
158+
"fuse ds", types.NamespacedName{Namespace: fuses.Namespace, Name: fuses.Name})
159+
return false, nil
160+
}
161+
162+
fusesToUpdate := fuses.DeepCopy()
163+
changed, err = t.checkAndSetFuseChanges(oldValue, latestValue, fusesToUpdate)
164+
if err != nil {
165+
return false, err
166+
}
167+
if !changed {
168+
t.Log.V(1).Info("syncFuseSpec: no differences detected about fuse")
169+
return false, nil
170+
}
171+
172+
if reflect.DeepEqual(fuses, fusesToUpdate) {
173+
t.Log.V(1).Info("syncFuseSpec: no differences detected about fuse after equality check")
174+
return false, nil
175+
}
176+
177+
t.Log.Info("syncFuseSpec: some fields are changed in fuse, try to update the fuse daemonset",
178+
"fuse ds", types.NamespacedName{Namespace: fusesToUpdate.Namespace, Name: fusesToUpdate.Name})
179+
if err = t.Client.Update(context.TODO(), fusesToUpdate); err != nil {
180+
t.Log.Error(err, "syncFuseSpec: failed to update the fuse daemonset spec",
181+
"fuse ds", types.NamespacedName{Namespace: fusesToUpdate.Namespace, Name: fusesToUpdate.Name})
182+
return false, err
183+
}
184+
185+
return true, nil
186+
}
187+
188+
// checkAndSetFuseChanges applies the supported fuse template changes onto fusesToUpdate.
189+
//
190+
// Fields the thin chart renders verbatim (resources, image, imagePullPolicy) are compared against
191+
// the live daemonset, so that manual drift is corrected as well. Fields the chart merges with
192+
// entries of its own (envs, volumes, volumeMounts, labels, annotations) are compared against the
193+
// last synced value instead, and only the value-derived entries are replaced, so that the chart's
194+
// own entries survive.
195+
//
196+
// Some fuse fields are deliberately left out:
197+
// - nodeSelector, because transformFuse injects the fuse scheduling label that CSI relies on to
198+
// place fuse pods, so changing it after creation breaks mounting.
199+
// - hostNetwork, hostPID, targetPath, ports, command, args and the probes, whose post-ready change
200+
// semantics need their own discussion.
201+
// - configValue, which updateFuseConfigOnChange already reconciles.
202+
func (t *ThinEngine) checkAndSetFuseChanges(oldValue, latestValue *ThinValue, fusesToUpdate *appsv1.DaemonSet) (changed bool, err error) {
203+
// volumes
204+
if !isSliceEqual(oldValue.Fuse.Volumes, latestValue.Fuse.Volumes) {
205+
t.Log.Info("syncFuseSpec: volumes changed", "old", oldValue.Fuse.Volumes, "new", latestValue.Fuse.Volumes)
206+
fusesToUpdate.Spec.Template.Spec.Volumes = append(
207+
utils.GetVolumesDifference(fusesToUpdate.Spec.Template.Spec.Volumes, oldValue.Fuse.Volumes),
208+
latestValue.Fuse.Volumes...)
209+
oldValue.Fuse.Volumes = latestValue.Fuse.Volumes
210+
changed = true
211+
}
212+
213+
// labels
214+
if !isMapEqual(oldValue.Fuse.Labels, latestValue.Fuse.Labels) {
215+
t.Log.Info("syncFuseSpec: labels changed", "old", oldValue.Fuse.Labels, "new", latestValue.Fuse.Labels)
216+
fusesToUpdate.Spec.Template.Labels = utils.UnionMapsWithOverride(
217+
utils.GetMapsDifference(fusesToUpdate.Spec.Template.Labels, oldValue.Fuse.Labels),
218+
latestValue.Fuse.Labels)
219+
oldValue.Fuse.Labels = latestValue.Fuse.Labels
220+
changed = true
221+
}
222+
223+
// annotations
224+
if !isMapEqual(oldValue.Fuse.Annotations, latestValue.Fuse.Annotations) {
225+
t.Log.Info("syncFuseSpec: annotations changed", "old", oldValue.Fuse.Annotations, "new", latestValue.Fuse.Annotations)
226+
fusesToUpdate.Spec.Template.Annotations = utils.UnionMapsWithOverride(
227+
utils.GetMapsDifference(fusesToUpdate.Spec.Template.Annotations, oldValue.Fuse.Annotations),
228+
latestValue.Fuse.Annotations)
229+
oldValue.Fuse.Annotations = latestValue.Fuse.Annotations
230+
changed = true
231+
}
232+
233+
containerIdx := utils.GetContainerIndex(fusesToUpdate.Spec.Template.Spec.Containers, fuseContainerName)
234+
if containerIdx < 0 {
235+
t.Log.Info("syncFuseSpec: fuse container not found in the fuse daemonset, skip syncing the container spec",
236+
"container", fuseContainerName)
237+
return changed, nil
238+
}
239+
container := &fusesToUpdate.Spec.Template.Spec.Containers[containerIdx]
240+
241+
// resources
242+
latestResources, err := utils.TransformInternalResourcesToCoreV1Resources(latestValue.Fuse.Resources)
243+
if err != nil {
244+
return false, err
245+
}
246+
if !utils.ResourceRequirementsEqual(container.Resources, latestResources) {
247+
t.Log.Info("syncFuseSpec: resources changed", "old", container.Resources, "new", latestResources)
248+
container.Resources = latestResources
249+
oldValue.Fuse.Resources = latestValue.Fuse.Resources
250+
changed = true
251+
}
252+
253+
// image
254+
if latestImage := composeImage(latestValue.Fuse.Image, latestValue.Fuse.ImageTag); container.Image != latestImage {
255+
t.Log.Info("syncFuseSpec: image changed", "old", container.Image, "new", latestImage)
256+
container.Image = latestImage
257+
oldValue.Fuse.Image = latestValue.Fuse.Image
258+
oldValue.Fuse.ImageTag = latestValue.Fuse.ImageTag
259+
changed = true
260+
}
261+
262+
// imagePullPolicy
263+
// An empty value means the chart falls back to the Kubernetes default, so leave the daemonset
264+
// alone instead of clearing a policy that is already in effect.
265+
if latestPullPolicy := corev1.PullPolicy(latestValue.Fuse.ImagePullPolicy); latestPullPolicy != "" &&
266+
container.ImagePullPolicy != latestPullPolicy {
267+
t.Log.Info("syncFuseSpec: image pull policy changed", "old", container.ImagePullPolicy, "new", latestPullPolicy)
268+
container.ImagePullPolicy = latestPullPolicy
269+
oldValue.Fuse.ImagePullPolicy = latestValue.Fuse.ImagePullPolicy
270+
changed = true
271+
}
272+
273+
// envs
274+
if !isSliceEqual(oldValue.Fuse.Envs, latestValue.Fuse.Envs) {
275+
t.Log.Info("syncFuseSpec: env variables changed", "old", oldValue.Fuse.Envs, "new", latestValue.Fuse.Envs)
276+
container.Env = append(
277+
utils.GetEnvsDifference(container.Env, oldValue.Fuse.Envs),
278+
latestValue.Fuse.Envs...)
279+
oldValue.Fuse.Envs = latestValue.Fuse.Envs
280+
changed = true
281+
}
282+
283+
// volumeMounts
284+
if !isSliceEqual(oldValue.Fuse.VolumeMounts, latestValue.Fuse.VolumeMounts) {
285+
t.Log.Info("syncFuseSpec: volume mounts changed", "old", oldValue.Fuse.VolumeMounts, "new", latestValue.Fuse.VolumeMounts)
286+
container.VolumeMounts = append(
287+
utils.GetVolumeMountsDifference(container.VolumeMounts, oldValue.Fuse.VolumeMounts),
288+
latestValue.Fuse.VolumeMounts...)
289+
oldValue.Fuse.VolumeMounts = latestValue.Fuse.VolumeMounts
290+
changed = true
291+
}
292+
293+
// lifecycle
294+
if !reflect.DeepEqual(oldValue.Fuse.Lifecycle, latestValue.Fuse.Lifecycle) {
295+
t.Log.Info("syncFuseSpec: lifecycle changed", "old", oldValue.Fuse.Lifecycle, "new", latestValue.Fuse.Lifecycle)
296+
container.Lifecycle = latestValue.Fuse.Lifecycle
297+
oldValue.Fuse.Lifecycle = latestValue.Fuse.Lifecycle
298+
changed = true
299+
}
300+
301+
return changed, nil
302+
}
303+
304+
func composeImage(image, imageTag string) string {
305+
if len(imageTag) == 0 {
306+
return image
307+
}
308+
return image + ":" + imageTag
309+
}
310+
311+
// isSliceEqual treats a nil slice and an empty slice as equal, because a value that round trips
312+
// through the values ConfigMap loses that distinction.
313+
func isSliceEqual[T any](a, b []T) bool {
314+
if len(a) == 0 && len(b) == 0 {
315+
return true
316+
}
317+
return reflect.DeepEqual(a, b)
318+
}
319+
320+
// isMapEqual treats a nil map and an empty map as equal, for the same reason as isSliceEqual.
321+
func isMapEqual(a, b map[string]string) bool {
322+
if len(a) == 0 && len(b) == 0 {
323+
return true
324+
}
325+
return reflect.DeepEqual(a, b)
326+
}

0 commit comments

Comments
 (0)